mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 07:30:53 +02:00
Delete internal directory
This commit is contained in:
@@ -1,48 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
// compileAgentSubgraph wraps an Agent canvas node as an Eino subgraph (AddGraphNode best practice).
|
||||
func compileAgentSubgraph(_ context.Context, node graphNode) (compose.AnyGraph, error) {
|
||||
n := node
|
||||
prepareID := n.ID + "__agent_prepare"
|
||||
executeID := n.ID + "__agent_execute"
|
||||
finalizeID := n.ID + "__agent_finalize"
|
||||
g := compose.NewGraph[WorkflowNodeOutput, WorkflowNodeOutput]()
|
||||
_ = g.AddLambdaNode(prepareID, compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
if input == nil {
|
||||
input = WorkflowNodeOutput{}
|
||||
}
|
||||
input["agent_subgraph_stage"] = "prepare"
|
||||
input["agent_node_id"] = n.ID
|
||||
return input, nil
|
||||
}))
|
||||
_ = g.AddLambdaNode(executeID, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
_ = g.AddLambdaNode(finalizeID, compose.InvokableLambda(func(_ context.Context, output WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
if output == nil {
|
||||
output = WorkflowNodeOutput{}
|
||||
}
|
||||
output["agent_subgraph_stage"] = "finalize"
|
||||
output["agent_node_id"] = n.ID
|
||||
return output, nil
|
||||
}))
|
||||
if err := g.AddEdge(compose.START, prepareID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(prepareID, executeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(executeID, finalizeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := g.AddEdge(finalizeID, compose.END); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FieldBinding selects a value from workflow state (replaces {{...}} templates).
|
||||
type FieldBinding struct {
|
||||
From string `json:"from"` // inputs | previous | <nodeId>
|
||||
Field string `json:"field"` // e.g. output, message
|
||||
}
|
||||
|
||||
func parseFieldBinding(cfg map[string]any, keys ...string) (FieldBinding, bool) {
|
||||
for _, key := range keys {
|
||||
if cfg == nil {
|
||||
continue
|
||||
}
|
||||
raw, ok := cfg[key]
|
||||
if !ok || raw == nil {
|
||||
continue
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case map[string]any:
|
||||
return FieldBinding{
|
||||
From: strings.TrimSpace(fmt.Sprint(v["from"])),
|
||||
Field: strings.TrimSpace(fmt.Sprint(v["field"])),
|
||||
}, true
|
||||
case string:
|
||||
s := strings.TrimSpace(v)
|
||||
if s == "" {
|
||||
continue
|
||||
}
|
||||
var b FieldBinding
|
||||
if err := json.Unmarshal([]byte(s), &b); err == nil && (b.From != "" || b.Field != "") {
|
||||
return b, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return FieldBinding{}, false
|
||||
}
|
||||
|
||||
func defaultBinding(from, field string) FieldBinding {
|
||||
return FieldBinding{From: from, Field: field}
|
||||
}
|
||||
|
||||
func resolveBinding(b FieldBinding, state *WorkflowLocalState) any {
|
||||
from := strings.TrimSpace(b.From)
|
||||
field := strings.TrimSpace(b.Field)
|
||||
if field == "" {
|
||||
field = "output"
|
||||
}
|
||||
if from == "" || from == "previous" || from == "prev" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.LastOutput, field)
|
||||
}
|
||||
if field == "output" && state.LastOutput != nil {
|
||||
return state.LastOutput["output"]
|
||||
}
|
||||
return valueFromPath("previous."+field, state)
|
||||
}
|
||||
if from == "inputs" || from == "input" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Inputs, field)
|
||||
}
|
||||
if field == "" {
|
||||
return state.Inputs
|
||||
}
|
||||
return valueFromPath("inputs."+field, state)
|
||||
}
|
||||
if from == "outputs" {
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(state.Outputs, field)
|
||||
}
|
||||
return valueFromPath("outputs."+field, state)
|
||||
}
|
||||
if strings.HasPrefix(field, "$") || strings.HasPrefix(field, ".") {
|
||||
return evalJSONPathValue(valueFromPath(from, state), field)
|
||||
}
|
||||
return valueFromPath(from+"."+field, state)
|
||||
}
|
||||
|
||||
func resolveBindingString(b FieldBinding, state *WorkflowLocalState) string {
|
||||
return strings.TrimSpace(fmt.Sprint(resolveBinding(b, state)))
|
||||
}
|
||||
|
||||
func resolveNodeInputBinding(cfg map[string]any, state *WorkflowLocalState) string {
|
||||
if b, ok := parseFieldBinding(cfg, "input_binding"); ok {
|
||||
return resolveBindingString(b, state)
|
||||
}
|
||||
// legacy template field removed — default previous.output
|
||||
return resolveBindingString(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func resolveOutputSourceBinding(cfg map[string]any, state *WorkflowLocalState) any {
|
||||
if b, ok := parseFieldBinding(cfg, "source_binding"); ok {
|
||||
return resolveBinding(b, state)
|
||||
}
|
||||
return resolveBinding(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func resolveHITLPromptBinding(cfg map[string]any, state *WorkflowLocalState) string {
|
||||
if b, ok := parseFieldBinding(cfg, "prompt_binding"); ok {
|
||||
return resolveBindingString(b, state)
|
||||
}
|
||||
if s := cfgString(cfg, "prompt"); s != "" {
|
||||
return s
|
||||
}
|
||||
return resolveBindingString(defaultBinding("previous", "output"), state)
|
||||
}
|
||||
|
||||
func toolArgumentBindings(cfg map[string]any) map[string]FieldBinding {
|
||||
raw, ok := cfg["argument_bindings"].(map[string]any)
|
||||
if !ok || len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]FieldBinding, len(raw))
|
||||
for argName, v := range raw {
|
||||
m, ok := v.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
out[argName] = FieldBinding{
|
||||
From: strings.TrimSpace(fmt.Sprint(m["from"])),
|
||||
Field: strings.TrimSpace(fmt.Sprint(m["field"])),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func resolveToolArguments(cfg map[string]any, state *WorkflowLocalState) (map[string]interface{}, error) {
|
||||
bindings := toolArgumentBindings(cfg)
|
||||
if len(bindings) > 0 {
|
||||
args := make(map[string]interface{}, len(bindings))
|
||||
for k, b := range bindings {
|
||||
args[k] = resolveBinding(b, state)
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
raw := cfgString(cfg, "arguments")
|
||||
if raw == "" {
|
||||
return map[string]interface{}{}, nil
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args == nil {
|
||||
args = map[string]interface{}{}
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// fileCheckPointStore persists Eino workflow checkpoints on disk (per run id).
|
||||
type fileCheckPointStore struct {
|
||||
dir string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func newFileCheckPointStore(dir string) (*fileCheckPointStore, error) {
|
||||
dir = strings.TrimSpace(dir)
|
||||
if dir == "" {
|
||||
dir = filepath.Join("data", "workflow-checkpoints")
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create workflow checkpoint dir: %w", err)
|
||||
}
|
||||
return &fileCheckPointStore{dir: dir}, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) path(id string) (string, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return "", fmt.Errorf("checkpoint id is empty")
|
||||
}
|
||||
if strings.Contains(id, "..") || strings.ContainsAny(id, `/\`) {
|
||||
return "", fmt.Errorf("invalid checkpoint id")
|
||||
}
|
||||
return filepath.Join(s.dir, id+".ckpt"), nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Get(_ context.Context, checkPointID string) ([]byte, bool, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
return data, true, nil
|
||||
}
|
||||
|
||||
func (s *fileCheckPointStore) Set(_ context.Context, checkPointID string, checkPoint []byte) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
p, err := s.path(checkPointID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p + ".tmp"
|
||||
if err := os.WriteFile(tmp, checkPoint, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p)
|
||||
}
|
||||
@@ -1,782 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/openai"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type DraftTool struct {
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type DraftOptions struct {
|
||||
IncludeObjective bool `json:"include_objective"`
|
||||
AllowSchedule bool `json:"allow_schedule"`
|
||||
AllowHighRisk bool `json:"allow_high_risk"`
|
||||
}
|
||||
|
||||
type DraftRequest struct {
|
||||
Prompt string `json:"prompt"`
|
||||
Options DraftOptions `json:"options"`
|
||||
AvailableTools []DraftTool `json:"available_tools,omitempty"`
|
||||
}
|
||||
|
||||
type DraftMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type DraftCapability struct {
|
||||
Label string `json:"label"`
|
||||
ToolName string `json:"tool_name,omitempty"`
|
||||
ToolCandidates []string `json:"tool_candidates,omitempty"`
|
||||
}
|
||||
|
||||
type DraftAudit struct {
|
||||
Savable bool `json:"savable"`
|
||||
Validation []string `json:"validation,omitempty"`
|
||||
MissingFields []string `json:"missing_fields,omitempty"`
|
||||
RiskWarnings []string `json:"risk_warnings,omitempty"`
|
||||
Assumptions []string `json:"assumptions,omitempty"`
|
||||
HighRisk bool `json:"high_risk"`
|
||||
NeedsHITL bool `json:"needs_hitl"`
|
||||
}
|
||||
|
||||
type DraftResult struct {
|
||||
Graph *graphDef `json:"graph"`
|
||||
Meta DraftMeta `json:"meta"`
|
||||
Generator string `json:"generator"`
|
||||
Audit DraftAudit `json:"audit"`
|
||||
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||
Stats map[string]int `json:"stats"`
|
||||
}
|
||||
|
||||
type llmDraftEnvelope struct {
|
||||
Graph graphDef `json:"graph"`
|
||||
Meta DraftMeta `json:"meta"`
|
||||
Capabilities []DraftCapability `json:"capabilities,omitempty"`
|
||||
Audit DraftAudit `json:"audit,omitempty"`
|
||||
}
|
||||
|
||||
type draftToolHint struct {
|
||||
Label string
|
||||
Keywords []string
|
||||
Tools []string
|
||||
}
|
||||
|
||||
var draftToolHints = []draftToolHint{
|
||||
{Label: "子域名发现", Keywords: []string{"子域名", "subdomain", "subfinder", "amass"}, Tools: []string{"subfinder", "amass"}},
|
||||
{Label: "端口扫描", Keywords: []string{"端口", "port", "nmap", "rustscan", "masscan"}, Tools: []string{"nmap", "rustscan", "masscan"}},
|
||||
{Label: "漏洞扫描", Keywords: []string{"漏洞", "vuln", "漏洞扫描", "nuclei", "nikto", "zap"}, Tools: []string{"nuclei", "nikto", "zap"}},
|
||||
{Label: "暴露面探测", Keywords: []string{"目录", "路径", "暴露页面", "dir", "ffuf", "gobuster", "feroxbuster"}, Tools: []string{"ffuf", "gobuster", "feroxbuster", "dirsearch"}},
|
||||
{Label: "证书与域名线索收集", Keywords: []string{"证书", "certificate", "crt"}, Tools: []string{"subfinder"}},
|
||||
{Label: "云配置审计", Keywords: []string{"云", "cloud", "配置审计", "prowler", "scout"}, Tools: []string{"prowler", "scout-suite"}},
|
||||
{Label: "容器安全检查", Keywords: []string{"容器", "镜像", "k8s", "kubernetes", "trivy", "kube"}, Tools: []string{"trivy", "kube-bench", "kube-hunter"}},
|
||||
{Label: "威胁情报收集", Keywords: []string{"情报", "威胁情报", "threat", "ioc", "virustotal", "shodan", "fofa"}, Tools: []string{"virustotal_search", "shodan_search", "fofa_search"}},
|
||||
}
|
||||
|
||||
var highRiskDraftRE = regexp.MustCompile(`(?i)(隔离|封禁|加固|修复|执行|命令|脚本|删除|清理|阻断|封锁|攻击|利用|getshell|shell|payload|exploit|isolate|block|execute|script|delete|exploit|payload)`)
|
||||
|
||||
func GenerateDraftFromNaturalLanguage(ctx context.Context, req DraftRequest) (*DraftResult, error) {
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, fmt.Errorf("工作流需求不能为空")
|
||||
}
|
||||
capabilities := detectDraftCapabilities(prompt, req.AvailableTools)
|
||||
wantsApproval := containsAnyFold(prompt, "审批", "确认", "审核", "负责人", "人工", "review", "approve", "approval", "human")
|
||||
wantsReport := containsAnyFold(prompt, "报告", "汇总", "输出", "通知", "任务", "工单", "report", "summary", "notify", "ticket")
|
||||
wantsCondition := containsAnyFold(prompt, "如果", "发现", "存在", "高危", "新增", "失败", "通过", "否则", "if", "when", "high", "critical", "new", "fail")
|
||||
highRisk := highRiskDraftRE.MatchString(prompt)
|
||||
|
||||
builder := &draftGraphBuilder{x: 120, y: 150}
|
||||
assumptions := make([]string, 0)
|
||||
riskWarnings := make([]string, 0)
|
||||
missingFields := make([]string, 0)
|
||||
|
||||
start := builder.add("start", "开始", map[string]any{"input_keys": "message, conversationId, projectId, target"}, 0)
|
||||
previous := start
|
||||
for _, capability := range capabilities {
|
||||
hasTool := strings.TrimSpace(capability.ToolName) != ""
|
||||
var id string
|
||||
if hasTool {
|
||||
id = builder.add("tool", capability.Label, map[string]any{
|
||||
"tool_name": capability.ToolName,
|
||||
"arguments": `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`,
|
||||
"timeout_seconds": "120",
|
||||
"join_strategy": "all_merge",
|
||||
}, 0)
|
||||
} else {
|
||||
id = builder.add("agent", capability.Label, map[string]any{
|
||||
"agent_mode": "eino_single",
|
||||
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"instruction": capability.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt,
|
||||
"output_key": "agent_result",
|
||||
"join_strategy": "all_merge",
|
||||
"missing_tool_candidates": strings.Join(capability.ToolCandidates, ", "),
|
||||
}, 0)
|
||||
if len(capability.ToolCandidates) > 0 {
|
||||
assumptions = append(assumptions, capability.Label+" 未匹配到已启用工具,已生成 Agent 草稿节点。")
|
||||
missingFields = append(missingFields, capability.Label+": 选择或启用对应 MCP 工具")
|
||||
}
|
||||
}
|
||||
builder.connect(previous, id, "", nil)
|
||||
previous = id
|
||||
}
|
||||
|
||||
openConditionID := ""
|
||||
if wantsCondition {
|
||||
expr := `{{previous.output}} != ""`
|
||||
label := "是否满足触发条件"
|
||||
if highRisk {
|
||||
expr = `{{previous.output}} contains "高危"`
|
||||
label = "是否需要高风险处置"
|
||||
}
|
||||
condition := builder.add("condition", label, map[string]any{"expression": expr, "join_strategy": "all_merge"}, 0)
|
||||
builder.connect(previous, condition, "", nil)
|
||||
openConditionID = condition
|
||||
report := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||
"output_key": "result",
|
||||
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"static_value": "",
|
||||
"join_strategy": "all_merge",
|
||||
}, 130)
|
||||
builder.connect(condition, report, "否", map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"})
|
||||
previous = condition
|
||||
}
|
||||
|
||||
insertedHITL := false
|
||||
if highRisk {
|
||||
if !req.Options.AllowHighRisk || wantsApproval {
|
||||
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||
"prompt": "请确认是否允许继续执行高风险处置:" + prompt,
|
||||
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"reviewer": "human",
|
||||
"join_strategy": "all_merge",
|
||||
"risk_level": "high",
|
||||
}, 0)
|
||||
builder.connect(previous, approval, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||
if previous == openConditionID {
|
||||
openConditionID = ""
|
||||
}
|
||||
previous = approval
|
||||
insertedHITL = true
|
||||
}
|
||||
action := builder.add("agent", "执行受控处置", map[string]any{
|
||||
"agent_mode": "eino_single",
|
||||
"input_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"instruction": "仅在授权范围内生成处置步骤草稿;实际执行前必须由人工确认。用户需求:" + prompt,
|
||||
"output_key": "remediation_plan",
|
||||
"join_strategy": "all_merge",
|
||||
"risk_level": "high",
|
||||
"requires_human_confirmation": "true",
|
||||
}, 0)
|
||||
builder.connect(previous, action, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||
if previous == openConditionID {
|
||||
openConditionID = ""
|
||||
}
|
||||
previous = action
|
||||
if insertedHITL {
|
||||
riskWarnings = append(riskWarnings, "检测到高风险动作,已加入人工审批与 requires_human_confirmation 标记。")
|
||||
} else {
|
||||
riskWarnings = append(riskWarnings, "检测到高风险动作,已保留为草稿并添加 requires_human_confirmation 标记。")
|
||||
}
|
||||
} else if wantsApproval {
|
||||
approval := builder.add("hitl", "人工审批", map[string]any{
|
||||
"prompt": "请审核工作流阶段结果:" + prompt,
|
||||
"prompt_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"reviewer": "human",
|
||||
"join_strategy": "all_merge",
|
||||
}, 0)
|
||||
builder.connect(previous, approval, "", nil)
|
||||
previous = approval
|
||||
insertedHITL = true
|
||||
}
|
||||
|
||||
output := builder.add("output", draftOutputLabel(wantsReport), map[string]any{
|
||||
"output_key": "result",
|
||||
"source_binding": map[string]any{"from": "previous", "field": "output"},
|
||||
"static_value": "",
|
||||
"join_strategy": "all_merge",
|
||||
}, 0)
|
||||
builder.connect(previous, output, branchLabel(previous, openConditionID), branchConfig(previous, openConditionID, true))
|
||||
|
||||
graph := &graphDef{Nodes: builder.nodes, Edges: builder.edges, Config: map[string]any{
|
||||
"schema_version": 1,
|
||||
"generated_by": "natural_language",
|
||||
"source_prompt": prompt,
|
||||
}}
|
||||
if req.Options.IncludeObjective {
|
||||
graph.Config["objective"] = prompt
|
||||
}
|
||||
if req.Options.AllowSchedule && containsAnyFold(prompt, "每天", "每周", "定时", "周期", "持续", "daily", "weekly", "schedule", "monitor") {
|
||||
if containsAnyFold(prompt, "每天", "daily") {
|
||||
graph.Config["trigger_suggestion"] = "daily"
|
||||
} else {
|
||||
graph.Config["trigger_suggestion"] = "scheduled"
|
||||
}
|
||||
assumptions = append(assumptions, "已记录定时触发建议;保存后仍需在触发器或角色绑定处配置。")
|
||||
}
|
||||
|
||||
raw, _ := json.Marshal(graph)
|
||||
validation := make([]string, 0)
|
||||
if err := ValidateGraphJSON(ctx, string(raw)); err != nil {
|
||||
validation = append(validation, err.Error())
|
||||
}
|
||||
return &DraftResult{
|
||||
Graph: graph,
|
||||
Meta: DraftMeta{ID: draftSlug(prompt), Name: draftName(prompt), Description: prompt, Enabled: true},
|
||||
Generator: "deterministic",
|
||||
Audit: DraftAudit{
|
||||
Savable: len(validation) == 0,
|
||||
Validation: validation,
|
||||
MissingFields: missingFields,
|
||||
RiskWarnings: riskWarnings,
|
||||
Assumptions: assumptions,
|
||||
HighRisk: highRisk,
|
||||
NeedsHITL: insertedHITL,
|
||||
},
|
||||
Capabilities: capabilities,
|
||||
Stats: map[string]int{"nodes": len(graph.Nodes), "edges": len(graph.Edges)},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func GenerateDraftFromLLM(ctx context.Context, req DraftRequest, oa config.OpenAIConfig, logger *zap.Logger) (*DraftResult, error) {
|
||||
prompt := strings.TrimSpace(req.Prompt)
|
||||
if prompt == "" {
|
||||
return nil, fmt.Errorf("工作流需求不能为空")
|
||||
}
|
||||
if strings.TrimSpace(oa.APIKey) == "" || strings.TrimSpace(oa.Model) == "" {
|
||||
return nil, fmt.Errorf("AI 通道未配置 api_key 或 model")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
callCtx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
toolJSON, _ := json.Marshal(req.AvailableTools)
|
||||
systemPrompt := `你是 CyberStrikeAI 的工作流编排助手。你必须把用户的一句话需求转换为可保存的工作流草稿 JSON。
|
||||
只返回 JSON 对象,不要 Markdown,不要解释。JSON 必须符合:
|
||||
{
|
||||
"meta": {"id":"kebab-case-id","name":"短名称","description":"用户需求","enabled":true},
|
||||
"graph": {
|
||||
"nodes": [{"id":"start-1","type":"start","label":"显示名","position":{"x":120,"y":150},"config":{}}],
|
||||
"edges": [{"id":"edge-1","source":"start-1","target":"node-2","label":"","config":{}}],
|
||||
"config": {"schema_version":1,"generated_by":"llm","source_prompt":"用户原文"}
|
||||
},
|
||||
"capabilities": [{"label":"能力名","tool_name":"已匹配工具名","tool_candidates":["候选工具"]}],
|
||||
"audit": {"assumptions":[],"missing_fields":[],"risk_warnings":[]}
|
||||
}
|
||||
硬性规则:
|
||||
- 只能输出一个合法 JSON object;不要输出 JSON Schema、注释、解释文字、Markdown 代码块或多余前后缀。
|
||||
- 不要在 JSON 字符串值中使用竖线枚举写法;type 字段一次只能填写一个节点类型字符串。
|
||||
- 至少 1 个 start 和 1 个 output;output/end 不能有出边。
|
||||
- 节点 type 只能从这些字符串中选择:start、tool、agent、condition、hitl、output、end。
|
||||
- 每个 agent、tool、output 节点都必须配置唯一的 output_key;output 节点默认使用 result。
|
||||
- agent 节点必须配置 instruction 或 input_binding;默认 input_binding 为 {"from":"previous","field":"output"}。
|
||||
- output 节点必须配置 source_binding 或 static_value;默认 source_binding 为 {"from":"previous","field":"output"}。
|
||||
- tool 节点必须配置 tool_name、arguments、timeout_seconds;arguments 必须是合法 JSON 字符串。
|
||||
- 所有非 start 且可能有多个上游的节点必须配置 join_strategy:"all_merge"。
|
||||
- condition 最多 2 条出边,必须用 branch true/false,并用 label 是/否。
|
||||
- tool 节点只有在 available_tools 中存在启用工具时才使用,否则用 agent 节点并在 audit.missing_fields 写明缺失工具。
|
||||
- 高风险动作(执行脚本、隔离、封禁、删除、利用、payload、命令执行等)必须加入 hitl 审批,或在高风险节点 config 中标记 requires_human_confirmation:"true"、risk_level:"high"。
|
||||
- 不要生成会真实执行攻击的参数;工具参数使用 {{inputs.target}}、{{inputs.message}} 占位。
|
||||
- 所有节点 config 加 generated_by:"llm" 和 needs_review:"true"。`
|
||||
userPrompt := fmt.Sprintf("用户需求:%s\n\n选项:%+v\n\n可用工具 JSON:%s", prompt, req.Options, string(toolJSON))
|
||||
requestBody := map[string]interface{}{
|
||||
"model": strings.TrimSpace(oa.Model),
|
||||
"messages": []map[string]interface{}{
|
||||
{"role": "system", "content": systemPrompt},
|
||||
{"role": "user", "content": userPrompt},
|
||||
},
|
||||
"temperature": 0,
|
||||
"max_completion_tokens": 4096,
|
||||
"response_format": map[string]interface{}{"type": "json_object"},
|
||||
"thinking": map[string]interface{}{"type": "disabled"},
|
||||
}
|
||||
var apiResponse struct {
|
||||
Choices []struct {
|
||||
Message struct {
|
||||
Content string `json:"content"`
|
||||
ReasoningContent string `json:"reasoning_content"`
|
||||
} `json:"message"`
|
||||
} `json:"choices"`
|
||||
}
|
||||
client := openai.NewClient(&oa, nil, logger)
|
||||
if err := client.ChatCompletion(callCtx, requestBody, &apiResponse); err != nil {
|
||||
return nil, fmt.Errorf("调用大模型失败: %w", err)
|
||||
}
|
||||
if len(apiResponse.Choices) == 0 {
|
||||
return nil, fmt.Errorf("大模型未返回候选结果")
|
||||
}
|
||||
raw := strings.TrimSpace(apiResponse.Choices[0].Message.Content)
|
||||
if raw == "" {
|
||||
raw = strings.TrimSpace(apiResponse.Choices[0].Message.ReasoningContent)
|
||||
}
|
||||
env, err := parseLLMDraftEnvelope(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := normalizeLLMDraft(prompt, req, env)
|
||||
graphRaw, _ := json.Marshal(result.Graph)
|
||||
validation := make([]string, 0)
|
||||
if err := ValidateGraphJSON(ctx, string(graphRaw)); err != nil {
|
||||
validation = append(validation, err.Error())
|
||||
}
|
||||
result.Audit.Validation = validation
|
||||
result.Audit.Savable = len(validation) == 0
|
||||
if !result.Audit.Savable {
|
||||
return nil, fmt.Errorf("大模型生成的工作流未通过校验: %s", strings.Join(validation, ";"))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
type draftGraphBuilder struct {
|
||||
nodes []graphNode
|
||||
edges []graphEdge
|
||||
x float64
|
||||
y float64
|
||||
nodeSeq int
|
||||
edgeSeq int
|
||||
}
|
||||
|
||||
func (b *draftGraphBuilder) add(nodeType, label string, config map[string]any, yOffset float64) string {
|
||||
b.nodeSeq++
|
||||
id := fmt.Sprintf("%s-%d", nodeType, b.nodeSeq)
|
||||
if config == nil {
|
||||
config = make(map[string]any)
|
||||
}
|
||||
config["generated_by"] = "natural_language"
|
||||
config["needs_review"] = "true"
|
||||
b.nodes = append(b.nodes, graphNode{
|
||||
ID: id,
|
||||
Type: nodeType,
|
||||
Label: label,
|
||||
Position: graphPosition{X: b.x, Y: b.y + yOffset},
|
||||
Config: config,
|
||||
})
|
||||
b.x += 210
|
||||
return id
|
||||
}
|
||||
|
||||
func (b *draftGraphBuilder) connect(source, target, label string, config map[string]any) {
|
||||
b.edgeSeq++
|
||||
if config == nil {
|
||||
config = make(map[string]any)
|
||||
}
|
||||
b.edges = append(b.edges, graphEdge{ID: fmt.Sprintf("edge-ai-%d", b.edgeSeq), Source: source, Target: target, Label: label, Config: config})
|
||||
}
|
||||
|
||||
func parseLLMDraftEnvelope(raw string) (llmDraftEnvelope, error) {
|
||||
var lastErr error
|
||||
for _, candidate := range jsonObjectCandidates(raw) {
|
||||
var env llmDraftEnvelope
|
||||
if err := json.Unmarshal([]byte(candidate), &env); err == nil {
|
||||
if len(env.Graph.Nodes) == 0 {
|
||||
lastErr = fmt.Errorf("大模型 JSON 缺少 graph.nodes")
|
||||
continue
|
||||
}
|
||||
return env, nil
|
||||
} else {
|
||||
lastErr = err
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("大模型响应为空")
|
||||
}
|
||||
return llmDraftEnvelope{}, fmt.Errorf("解析大模型工作流 JSON 失败: %w", lastErr)
|
||||
}
|
||||
|
||||
func jsonObjectCandidates(raw string) []string {
|
||||
s := strings.TrimSpace(raw)
|
||||
s = strings.TrimPrefix(s, "```json")
|
||||
s = strings.TrimPrefix(s, "```")
|
||||
s = strings.TrimSuffix(s, "```")
|
||||
s = strings.TrimSpace(s)
|
||||
candidates := []string{s}
|
||||
if start := strings.Index(s, "{"); start >= 0 {
|
||||
if end := strings.LastIndex(s, "}"); end > start {
|
||||
candidates = append(candidates, s[start:end+1])
|
||||
}
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
func normalizeLLMDraft(prompt string, req DraftRequest, env llmDraftEnvelope) *DraftResult {
|
||||
g := env.Graph
|
||||
if g.Config == nil {
|
||||
g.Config = make(map[string]any)
|
||||
}
|
||||
g.Config["schema_version"] = 1
|
||||
g.Config["generated_by"] = "llm"
|
||||
g.Config["source_prompt"] = prompt
|
||||
if req.Options.IncludeObjective {
|
||||
g.Config["objective"] = prompt
|
||||
}
|
||||
enabledTools := enabledDraftToolNames(req.AvailableTools)
|
||||
usedOutputKeys := make(map[string]bool)
|
||||
nodeTypes := make(map[string]string, len(g.Nodes))
|
||||
for i := range g.Nodes {
|
||||
if strings.TrimSpace(g.Nodes[i].ID) == "" {
|
||||
g.Nodes[i].ID = fmt.Sprintf("%s-%d", firstNonEmpty(g.Nodes[i].Type, "node"), i+1)
|
||||
}
|
||||
if strings.TrimSpace(g.Nodes[i].Type) == "" {
|
||||
g.Nodes[i].Type = "agent"
|
||||
}
|
||||
if strings.TrimSpace(g.Nodes[i].Label) == "" {
|
||||
g.Nodes[i].Label = displayNodeType(g.Nodes[i].Type)
|
||||
}
|
||||
if g.Nodes[i].Position.X == 0 && g.Nodes[i].Position.Y == 0 {
|
||||
g.Nodes[i].Position = graphPosition{X: 120 + float64(i)*210, Y: 150}
|
||||
}
|
||||
if g.Nodes[i].Config == nil {
|
||||
g.Nodes[i].Config = make(map[string]any)
|
||||
}
|
||||
g.Nodes[i].Config["generated_by"] = "llm"
|
||||
g.Nodes[i].Config["needs_review"] = "true"
|
||||
normalizeLLMNodeConfig(prompt, &g.Nodes[i], enabledTools, usedOutputKeys)
|
||||
nodeTypes[g.Nodes[i].ID] = strings.ToLower(strings.TrimSpace(g.Nodes[i].Type))
|
||||
}
|
||||
conditionBranchCounts := make(map[string]int)
|
||||
for i := range g.Edges {
|
||||
if strings.TrimSpace(g.Edges[i].ID) == "" {
|
||||
g.Edges[i].ID = fmt.Sprintf("edge-llm-%d", i+1)
|
||||
}
|
||||
if g.Edges[i].Config == nil {
|
||||
g.Edges[i].Config = make(map[string]any)
|
||||
}
|
||||
normalizeLLMEdgeConfig(&g.Edges[i], nodeTypes, conditionBranchCounts)
|
||||
}
|
||||
audit := env.Audit
|
||||
highRisk := highRiskDraftRE.MatchString(prompt) || graphHasHighRisk(g)
|
||||
audit.HighRisk = highRisk
|
||||
audit.NeedsHITL = graphHasNodeType(g, "hitl")
|
||||
if highRisk && !audit.NeedsHITL && !graphHasConfirmation(g) {
|
||||
audit.RiskWarnings = append(audit.RiskWarnings, "大模型生成包含高风险语义,请补充人工审批或确认标记后再运行。")
|
||||
}
|
||||
if len(audit.RiskWarnings) == 0 && highRisk {
|
||||
audit.RiskWarnings = append(audit.RiskWarnings, "检测到高风险动作,已标记为需要重点审计。")
|
||||
}
|
||||
meta := env.Meta
|
||||
if strings.TrimSpace(meta.Description) == "" {
|
||||
meta.Description = prompt
|
||||
}
|
||||
if strings.TrimSpace(meta.Name) == "" {
|
||||
meta.Name = draftName(prompt)
|
||||
}
|
||||
if strings.TrimSpace(meta.ID) == "" {
|
||||
meta.ID = draftSlug(prompt)
|
||||
}
|
||||
meta.Enabled = true
|
||||
return &DraftResult{
|
||||
Graph: &g,
|
||||
Meta: meta,
|
||||
Generator: "llm",
|
||||
Audit: audit,
|
||||
Capabilities: env.Capabilities,
|
||||
Stats: map[string]int{"nodes": len(g.Nodes), "edges": len(g.Edges)},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLLMEdgeConfig(edge *graphEdge, nodeTypes map[string]string, conditionBranchCounts map[string]int) {
|
||||
if nodeTypes[strings.TrimSpace(edge.Source)] != "condition" {
|
||||
return
|
||||
}
|
||||
if conditionBranchHint(*edge) != "" {
|
||||
return
|
||||
}
|
||||
conditionBranchCounts[edge.Source]++
|
||||
branch := "true"
|
||||
label := "是"
|
||||
if conditionBranchCounts[edge.Source] > 1 {
|
||||
branch = "false"
|
||||
label = "否"
|
||||
}
|
||||
edge.Label = label
|
||||
edge.Config["branch"] = branch
|
||||
}
|
||||
|
||||
func normalizeLLMNodeConfig(prompt string, node *graphNode, enabledTools map[string]bool, usedOutputKeys map[string]bool) {
|
||||
nodeType := strings.ToLower(strings.TrimSpace(node.Type))
|
||||
switch nodeType {
|
||||
case "start":
|
||||
if cfgString(node.Config, "input_keys") == "" {
|
||||
node.Config["input_keys"] = "message, conversationId, projectId, target"
|
||||
}
|
||||
case "tool":
|
||||
toolName := cfgString(node.Config, "tool_name")
|
||||
if toolName == "" || !enabledTools[strings.ToLower(toolName)] {
|
||||
node.Type = "agent"
|
||||
node.Config["missing_tool_name"] = toolName
|
||||
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||
return
|
||||
}
|
||||
if cfgString(node.Config, "arguments") == "" {
|
||||
node.Config["arguments"] = `{"target":"{{inputs.target}}","message":"{{inputs.message}}"}`
|
||||
}
|
||||
if cfgString(node.Config, "timeout_seconds") == "" {
|
||||
node.Config["timeout_seconds"] = "120"
|
||||
}
|
||||
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "tool_result"))
|
||||
ensureJoinStrategy(node)
|
||||
case "agent":
|
||||
normalizeAgentDraftConfig(prompt, node, usedOutputKeys)
|
||||
case "condition":
|
||||
if cfgString(node.Config, "expression") == "" {
|
||||
node.Config["expression"] = `{{previous.output}} != ""`
|
||||
}
|
||||
ensureJoinStrategy(node)
|
||||
case "hitl":
|
||||
if cfgString(node.Config, "prompt") == "" {
|
||||
node.Config["prompt"] = "请审核工作流阶段结果:" + prompt
|
||||
}
|
||||
if cfgString(node.Config, "reviewer") == "" {
|
||||
node.Config["reviewer"] = "human"
|
||||
}
|
||||
ensureJoinStrategy(node)
|
||||
case "output":
|
||||
ensureNodeOutputKey(node, usedOutputKeys, "result")
|
||||
if cfgString(node.Config, "static_value") == "" {
|
||||
if _, ok := parseFieldBinding(node.Config, "source_binding"); !ok {
|
||||
node.Config["source_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||
}
|
||||
}
|
||||
ensureJoinStrategy(node)
|
||||
case "end":
|
||||
ensureJoinStrategy(node)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeAgentDraftConfig(prompt string, node *graphNode, usedOutputKeys map[string]bool) {
|
||||
if cfgString(node.Config, "agent_mode") == "" {
|
||||
node.Config["agent_mode"] = "eino_single"
|
||||
}
|
||||
if cfgString(node.Config, "instruction") == "" {
|
||||
node.Config["instruction"] = node.Label + "。根据用户需求执行安全流程步骤,并输出结构化结果:" + prompt
|
||||
}
|
||||
if _, ok := parseFieldBinding(node.Config, "input_binding"); !ok {
|
||||
node.Config["input_binding"] = map[string]any{"from": "previous", "field": "output"}
|
||||
}
|
||||
ensureNodeOutputKey(node, usedOutputKeys, draftOutputKeyBase(node, "agent_result"))
|
||||
ensureJoinStrategy(node)
|
||||
}
|
||||
|
||||
func ensureJoinStrategy(node *graphNode) {
|
||||
if cfgString(node.Config, "join_strategy") == "" {
|
||||
node.Config["join_strategy"] = "all_merge"
|
||||
}
|
||||
}
|
||||
|
||||
func ensureNodeOutputKey(node *graphNode, used map[string]bool, fallback string) {
|
||||
current := sanitizeOutputKey(cfgString(node.Config, "output_key"))
|
||||
if current == "" {
|
||||
current = sanitizeOutputKey(fallback)
|
||||
}
|
||||
if current == "" {
|
||||
current = "result"
|
||||
}
|
||||
base := current
|
||||
for i := 2; used[current]; i++ {
|
||||
current = fmt.Sprintf("%s_%d", base, i)
|
||||
}
|
||||
node.Config["output_key"] = current
|
||||
used[current] = true
|
||||
}
|
||||
|
||||
func draftOutputKeyBase(node *graphNode, fallback string) string {
|
||||
if name := cfgString(node.Config, "tool_name"); name != "" {
|
||||
return name + "_result"
|
||||
}
|
||||
if node.ID != "" {
|
||||
return node.ID + "_result"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func sanitizeOutputKey(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
var b strings.Builder
|
||||
lastUnderscore := false
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
b.WriteRune(r)
|
||||
lastUnderscore = false
|
||||
continue
|
||||
}
|
||||
if b.Len() > 0 && !lastUnderscore {
|
||||
b.WriteByte('_')
|
||||
lastUnderscore = true
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "_")
|
||||
}
|
||||
|
||||
func enabledDraftToolNames(tools []DraftTool) map[string]bool {
|
||||
names := make(map[string]bool, len(tools)*2)
|
||||
for _, tool := range tools {
|
||||
if !tool.Enabled {
|
||||
continue
|
||||
}
|
||||
if key := strings.ToLower(strings.TrimSpace(tool.Key)); key != "" {
|
||||
names[key] = true
|
||||
}
|
||||
if name := strings.ToLower(strings.TrimSpace(tool.Name)); name != "" {
|
||||
names[name] = true
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func graphHasNodeType(g graphDef, nodeType string) bool {
|
||||
for _, node := range g.Nodes {
|
||||
if strings.EqualFold(node.Type, nodeType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphHasConfirmation(g graphDef) bool {
|
||||
for _, node := range g.Nodes {
|
||||
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func graphHasHighRisk(g graphDef) bool {
|
||||
for _, node := range g.Nodes {
|
||||
if cfgString(node.Config, "risk_level") == "high" || cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||
return true
|
||||
}
|
||||
if highRiskDraftRE.MatchString(node.Label) || highRiskDraftRE.MatchString(cfgString(node.Config, "instruction")) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func detectDraftCapabilities(prompt string, tools []DraftTool) []DraftCapability {
|
||||
capabilities := make([]DraftCapability, 0)
|
||||
for _, hint := range draftToolHints {
|
||||
if containsAnyFold(prompt, hint.Keywords...) {
|
||||
capabilities = append(capabilities, DraftCapability{
|
||||
Label: hint.Label,
|
||||
ToolName: matchDraftTool(hint.Tools, tools),
|
||||
ToolCandidates: append([]string(nil), hint.Tools...),
|
||||
})
|
||||
}
|
||||
}
|
||||
if len(capabilities) == 0 {
|
||||
capabilities = append(capabilities, DraftCapability{Label: "节点能力", ToolCandidates: nil})
|
||||
}
|
||||
return capabilities
|
||||
}
|
||||
|
||||
func matchDraftTool(candidates []string, tools []DraftTool) string {
|
||||
if len(candidates) == 0 || len(tools) == 0 {
|
||||
return ""
|
||||
}
|
||||
for _, enabledOnly := range []bool{true, false} {
|
||||
for _, candidate := range candidates {
|
||||
candidate = strings.ToLower(strings.TrimSpace(candidate))
|
||||
for _, tool := range tools {
|
||||
if enabledOnly && !tool.Enabled {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(strings.TrimSpace(firstNonEmpty(tool.Key, tool.Name)))
|
||||
if key != "" && strings.Contains(key, candidate) {
|
||||
return firstNonEmpty(tool.Key, tool.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func containsAnyFold(text string, needles ...string) bool {
|
||||
lower := strings.ToLower(text)
|
||||
for _, needle := range needles {
|
||||
if strings.Contains(lower, strings.ToLower(needle)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func draftOutputLabel(wantsReport bool) string {
|
||||
if wantsReport {
|
||||
return "输出报告"
|
||||
}
|
||||
return "输出"
|
||||
}
|
||||
|
||||
func branchLabel(source, conditionID string) string {
|
||||
if source == conditionID && conditionID != "" {
|
||||
return "是"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func branchConfig(source, conditionID string, yes bool) map[string]any {
|
||||
if source != conditionID || conditionID == "" {
|
||||
return nil
|
||||
}
|
||||
if yes {
|
||||
return map[string]any{"condition": `{{previous.matched}} == "true"`, "branch": "true"}
|
||||
}
|
||||
return map[string]any{"condition": `{{previous.matched}} == "false"`, "branch": "false"}
|
||||
}
|
||||
|
||||
func draftName(prompt string) string {
|
||||
runes := []rune(strings.TrimSpace(prompt))
|
||||
if len(runes) > 22 {
|
||||
return string(runes[:22]) + "..."
|
||||
}
|
||||
return string(runes)
|
||||
}
|
||||
|
||||
func draftSlug(prompt string) string {
|
||||
lower := strings.ToLower(strings.TrimSpace(prompt))
|
||||
var b strings.Builder
|
||||
lastDash := false
|
||||
for _, r := range lower {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
lastDash = false
|
||||
continue
|
||||
}
|
||||
if !lastDash && b.Len() > 0 {
|
||||
b.WriteByte('-')
|
||||
lastDash = true
|
||||
}
|
||||
}
|
||||
slug := strings.Trim(b.String(), "-")
|
||||
if slug != "" {
|
||||
if len(slug) > 48 {
|
||||
return strings.Trim(slug[:48], "-")
|
||||
}
|
||||
return slug
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(lower))
|
||||
if !utf8.ValidString(lower) || lower == "" {
|
||||
lower = "workflow"
|
||||
}
|
||||
return fmt.Sprintf("ai-workflow-%x", h.Sum32())
|
||||
}
|
||||
@@ -1,213 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
)
|
||||
|
||||
func TestGenerateDraftFromNaturalLanguageHighRiskAddsHITLAndValidGraph(t *testing.T) {
|
||||
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||
Prompt: "对目标资产做端口扫描,如果发现高危端口就执行加固脚本,最后输出报告",
|
||||
Options: DraftOptions{
|
||||
IncludeObjective: true,
|
||||
AllowSchedule: false,
|
||||
AllowHighRisk: false,
|
||||
},
|
||||
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(result.Graph)
|
||||
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||
}
|
||||
if !result.Audit.HighRisk || !result.Audit.NeedsHITL || len(result.Audit.RiskWarnings) == 0 {
|
||||
t.Fatalf("audit did not flag high-risk HITL path: %#v", result.Audit)
|
||||
}
|
||||
var hasTool, hasHITL, hasConfirmation bool
|
||||
for _, node := range result.Graph.Nodes {
|
||||
if node.Type == "tool" && cfgString(node.Config, "tool_name") == "nmap" {
|
||||
hasTool = true
|
||||
}
|
||||
if node.Type == "hitl" {
|
||||
hasHITL = true
|
||||
}
|
||||
if cfgString(node.Config, "requires_human_confirmation") == "true" {
|
||||
hasConfirmation = true
|
||||
}
|
||||
}
|
||||
if !hasTool || !hasHITL || !hasConfirmation {
|
||||
t.Fatalf("expected nmap tool, HITL, and confirmation marker; tool=%v hitl=%v confirmation=%v", hasTool, hasHITL, hasConfirmation)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDraftAllowHighRiskStillLabelsConditionBranch(t *testing.T) {
|
||||
result, err := GenerateDraftFromNaturalLanguage(context.Background(), DraftRequest{
|
||||
Prompt: "如果漏洞扫描发现高危漏洞,允许生成执行修复脚本的草稿并输出报告",
|
||||
Options: DraftOptions{
|
||||
AllowHighRisk: true,
|
||||
},
|
||||
AvailableTools: []DraftTool{{Key: "nuclei", Name: "nuclei", Enabled: true}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraftFromNaturalLanguage: %v", err)
|
||||
}
|
||||
raw, _ := json.Marshal(result.Graph)
|
||||
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||
t.Fatalf("generated graph should validate: %v\n%s", err, raw)
|
||||
}
|
||||
branches := map[string]bool{}
|
||||
for _, edge := range result.Graph.Edges {
|
||||
if branch := cfgString(edge.Config, "branch"); branch != "" {
|
||||
branches[branch] = true
|
||||
}
|
||||
}
|
||||
if !branches["true"] || !branches["false"] {
|
||||
t.Fatalf("condition branches = %#v, want true and false", branches)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDraftFromLLMUsesOpenAICompatibleEndpoint(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
if r.URL.Path != "/chat/completions" {
|
||||
t.Fatalf("path = %s, want /chat/completions", r.URL.Path)
|
||||
}
|
||||
if got := r.Header.Get("Authorization"); got != "Bearer test-key" {
|
||||
t.Fatalf("authorization = %q", got)
|
||||
}
|
||||
var payload struct {
|
||||
Temperature float64 `json:"temperature"`
|
||||
ResponseFormat struct {
|
||||
Type string `json:"type"`
|
||||
} `json:"response_format"`
|
||||
Messages []struct {
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
} `json:"messages"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode request: %v", err)
|
||||
}
|
||||
if payload.Temperature != 0 || payload.ResponseFormat.Type != "json_object" {
|
||||
t.Fatalf("unexpected structured output controls: temperature=%v response_format=%#v", payload.Temperature, payload.ResponseFormat)
|
||||
}
|
||||
if len(payload.Messages) == 0 || strings.Contains(payload.Messages[0].Content, "start|tool|agent") {
|
||||
t.Fatalf("system prompt still contains pipe enum: %q", payload.Messages[0].Content)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"llm-port-scan\",\"name\":\"端口扫描\",\"description\":\"端口扫描\",\"enabled\":true},\"graph\":{\"nodes\":[{\"id\":\"start-1\",\"type\":\"start\",\"label\":\"开始\",\"position\":{\"x\":120,\"y\":150},\"config\":{\"input_keys\":\"message, target\"}},{\"id\":\"tool-2\",\"type\":\"tool\",\"label\":\"端口扫描\",\"position\":{\"x\":330,\"y\":150},\"config\":{\"tool_name\":\"nmap\",\"arguments\":\"{\\\"target\\\":\\\"{{inputs.target}}\\\"}\",\"timeout_seconds\":\"120\",\"join_strategy\":\"all_merge\"}},{\"id\":\"output-3\",\"type\":\"output\",\"label\":\"输出报告\",\"position\":{\"x\":540,\"y\":150},\"config\":{\"source_binding\":{\"from\":\"previous\",\"field\":\"output\"},\"join_strategy\":\"all_merge\"}}],\"edges\":[{\"id\":\"edge-1\",\"source\":\"start-1\",\"target\":\"tool-2\"},{\"id\":\"edge-2\",\"source\":\"tool-2\",\"target\":\"output-3\"}],\"config\":{\"schema_version\":1}},\"capabilities\":[{\"label\":\"端口扫描\",\"tool_name\":\"nmap\",\"tool_candidates\":[\"nmap\"]}],\"audit\":{\"assumptions\":[]}}"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
result, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||
Prompt: "对目标做端口扫描并输出报告",
|
||||
AvailableTools: []DraftTool{{Key: "nmap", Name: "nmap", Enabled: true}},
|
||||
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateDraftFromLLM: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Fatal("expected LLM endpoint to be called")
|
||||
}
|
||||
if result.Generator != "llm" || !result.Audit.Savable || result.Meta.ID != "llm-port-scan" {
|
||||
t.Fatalf("unexpected result: %#v", result)
|
||||
}
|
||||
for _, node := range result.Graph.Nodes {
|
||||
if node.Type == "output" && cfgString(node.Config, "output_key") != "result" {
|
||||
t.Fatalf("output_key = %q, want result", cfgString(node.Config, "output_key"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateDraftFromLLMReturnsErrorOnMalformedJSON(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"{\"meta\":{\"id\":\"bad\"},|\"graph\":{\"nodes\":[]}}"}}]}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
_, err := GenerateDraftFromLLM(context.Background(), DraftRequest{
|
||||
Prompt: "随便生成一个工作流,要求所有节点都用到输出变量",
|
||||
Options: DraftOptions{
|
||||
IncludeObjective: true,
|
||||
},
|
||||
}, config.OpenAIConfig{APIKey: "test-key", BaseURL: srv.URL, Model: "test-model"}, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected malformed JSON error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "解析大模型工作流 JSON 失败") {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLLMDraftRepairsMissingRequiredConfig(t *testing.T) {
|
||||
result := normalizeLLMDraft("随便生成一个工作流", DraftRequest{}, llmDraftEnvelope{
|
||||
Graph: graphDef{
|
||||
Nodes: []graphNode{
|
||||
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||
{ID: "agent-1", Type: "agent", Label: "分析", Config: map[string]any{}},
|
||||
{ID: "out-1", Type: "output", Label: "输出结果", Config: map[string]any{}},
|
||||
},
|
||||
Edges: []graphEdge{
|
||||
{ID: "e1", Source: "start-1", Target: "agent-1"},
|
||||
{ID: "e2", Source: "agent-1", Target: "out-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
raw, _ := json.Marshal(result.Graph)
|
||||
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||
}
|
||||
var agentKey, outputKey string
|
||||
for _, node := range result.Graph.Nodes {
|
||||
switch node.Type {
|
||||
case "agent":
|
||||
agentKey = cfgString(node.Config, "output_key")
|
||||
case "output":
|
||||
outputKey = cfgString(node.Config, "output_key")
|
||||
}
|
||||
}
|
||||
if agentKey == "" || outputKey != "result" {
|
||||
t.Fatalf("agentKey=%q outputKey=%q", agentKey, outputKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLLMDraftRepairsConditionBranches(t *testing.T) {
|
||||
result := normalizeLLMDraft("如果发现异常则输出详情,否则输出正常", DraftRequest{}, llmDraftEnvelope{
|
||||
Graph: graphDef{
|
||||
Nodes: []graphNode{
|
||||
{ID: "start-1", Type: "start", Label: "开始", Config: map[string]any{}},
|
||||
{ID: "cond-1", Type: "condition", Label: "判断", Config: map[string]any{"expression": `{{inputs.message}} != ""`}},
|
||||
{ID: "out-yes", Type: "output", Label: "异常", Config: map[string]any{}},
|
||||
{ID: "out-no", Type: "output", Label: "正常", Config: map[string]any{}},
|
||||
},
|
||||
Edges: []graphEdge{
|
||||
{ID: "e1", Source: "start-1", Target: "cond-1"},
|
||||
{ID: "e2", Source: "cond-1", Target: "out-yes"},
|
||||
{ID: "e3", Source: "cond-1", Target: "out-no"},
|
||||
},
|
||||
},
|
||||
})
|
||||
raw, _ := json.Marshal(result.Graph)
|
||||
if err := ValidateGraphJSON(context.Background(), string(raw)); err != nil {
|
||||
t.Fatalf("normalized graph should validate: %v\n%s", err, raw)
|
||||
}
|
||||
branches := map[string]bool{}
|
||||
for _, edge := range result.Graph.Edges {
|
||||
if edge.Source == "cond-1" {
|
||||
branches[cfgString(edge.Config, "branch")] = true
|
||||
}
|
||||
}
|
||||
if !branches["true"] || !branches["false"] {
|
||||
t.Fatalf("branches = %#v, want true and false", branches)
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type DryRunResult struct {
|
||||
Outputs map[string]any `json:"outputs"`
|
||||
NodeOutputs map[string]map[string]any `json:"nodeOutputs"`
|
||||
Executed []string `json:"executed"`
|
||||
Skipped []string `json:"skipped"`
|
||||
Trace []map[string]any `json:"trace"`
|
||||
Metrics map[string]any `json:"metrics"`
|
||||
ReplayScript []map[string]any `json:"replayScript"`
|
||||
}
|
||||
|
||||
func DryRunGraphJSON(ctx context.Context, graphJSON string, inputs map[string]any) (*DryRunResult, error) {
|
||||
g, err := parseGraph(graphJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := make(map[string]interface{}, len(inputs))
|
||||
for k, v := range inputs {
|
||||
in[k] = v
|
||||
}
|
||||
if _, ok := in["message"]; !ok {
|
||||
in["message"] = ""
|
||||
}
|
||||
state := newWorkflowLocalState(in, "dry-run")
|
||||
rt := &workflowRuntime{runID: "dry-run", idx: idx, state: state}
|
||||
trace := []map[string]any{}
|
||||
executedIDs := map[string]bool{}
|
||||
queue := findStartNodeIDs(idx)
|
||||
for len(queue) > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
nodeID := queue[0]
|
||||
queue = queue[1:]
|
||||
if executedIDs[nodeID] {
|
||||
continue
|
||||
}
|
||||
node := idx.nodes[nodeID]
|
||||
if !dryRunPredecessorsReady(idx, nodeID, executedIDs) {
|
||||
queue = append(queue, nodeID)
|
||||
continue
|
||||
}
|
||||
if err := prepareNodeInputState(rt, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
started := time.Now()
|
||||
out, proceed, status, errText := dryRunNode(node, state)
|
||||
out["duration_ms"] = time.Since(started).Milliseconds()
|
||||
out["status"] = status
|
||||
state.NodeOutputs[node.ID] = out
|
||||
state.LastOutput = out
|
||||
executedIDs[nodeID] = true
|
||||
if status == "skipped" {
|
||||
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, node.ID))
|
||||
} else {
|
||||
state.Executed = append(state.Executed, firstNonEmpty(node.Label, node.ID))
|
||||
}
|
||||
trace = append(trace, map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"label": firstNonEmpty(node.Label, node.ID),
|
||||
"type": node.Type,
|
||||
"status": status,
|
||||
"error": errText,
|
||||
"output": out,
|
||||
"previous": state.LastOutput,
|
||||
})
|
||||
if !proceed {
|
||||
continue
|
||||
}
|
||||
for edgeIdx, edge := range idx.outgoing[nodeID] {
|
||||
if edgeAllowed(edge, node, edgeIdx, state) {
|
||||
queue = append(queue, edge.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
for id, node := range idx.nodes {
|
||||
if !executedIDs[id] {
|
||||
state.Skipped = append(state.Skipped, firstNonEmpty(node.Label, id))
|
||||
}
|
||||
}
|
||||
return &DryRunResult{
|
||||
Outputs: state.Outputs,
|
||||
NodeOutputs: state.NodeOutputs,
|
||||
Executed: state.Executed,
|
||||
Skipped: state.Skipped,
|
||||
Trace: trace,
|
||||
Metrics: state.Metrics,
|
||||
ReplayScript: buildReplayScript(trace),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func dryRunPredecessorsReady(idx *graphIndex, nodeID string, executed map[string]bool) bool {
|
||||
for _, edge := range idx.incoming[nodeID] {
|
||||
if !executed[edge.Source] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func dryRunNode(node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "start":
|
||||
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
|
||||
case "condition":
|
||||
expr := cfgString(node.Config, "expression")
|
||||
matched := evalCondition(expr, state)
|
||||
return conditionOutputMap(node, expr, matched), true, "completed", ""
|
||||
case "output":
|
||||
key := cfgString(node.Config, "output_key")
|
||||
value := resolveOutputSourceBinding(node.Config, state)
|
||||
if static := cfgString(node.Config, "static_value"); static != "" {
|
||||
value = static
|
||||
}
|
||||
state.Outputs[key] = value
|
||||
return outputNodeOutputMap(node, key, value), true, "completed", ""
|
||||
case "end":
|
||||
value := resolveOutputSourceBinding(node.Config, state)
|
||||
if b, ok := parseFieldBinding(node.Config, "result_binding"); ok {
|
||||
value = resolveBinding(b, state)
|
||||
}
|
||||
return endOutputMap(node, value), false, "completed", ""
|
||||
case "tool":
|
||||
args, err := resolveToolArguments(node.Config, state)
|
||||
if err != nil {
|
||||
errText := fmt.Sprintf("工具参数不是合法 JSON:%v", err)
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
return toolOutputMap(node, "[dry-run] tool call skipped", cfgString(node.Config, "tool_name"), args, "dry-run", false), true, "simulated", ""
|
||||
case "agent":
|
||||
mode := firstNonEmpty(cfgString(node.Config, "agent_mode"), "eino_single")
|
||||
response := "[dry-run] agent execution skipped"
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = response
|
||||
}
|
||||
return agentOutputMap(node, response, mode, nil), true, "simulated", ""
|
||||
case "hitl":
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
return hitlOutputMap(node, "simulated", prompt, prompt, firstNonEmpty(cfgString(node.Config, "reviewer"), "human"), true), true, "simulated", ""
|
||||
default:
|
||||
return outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"reason": "未知节点类型"}), true, "skipped", "未知节点类型"
|
||||
}
|
||||
}
|
||||
|
||||
func buildReplayScript(trace []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(trace))
|
||||
for i, step := range trace {
|
||||
raw, _ := json.Marshal(step["output"])
|
||||
out = append(out, map[string]any{
|
||||
"step": i + 1,
|
||||
"nodeId": step["nodeId"],
|
||||
"type": step["type"],
|
||||
"status": step["status"],
|
||||
"output": string(raw),
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func hasConditionalOutgoingEdges(idx *graphIndex, nodeID string) bool {
|
||||
for _, edge := range idx.outgoing[nodeID] {
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
if cond != "" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func wireConditionBranch(
|
||||
wf *compose.Workflow[WorkflowInput, WorkflowOutput],
|
||||
nodeRefs map[string]*compose.WorkflowNode,
|
||||
idx *graphIndex,
|
||||
condID string,
|
||||
condNode graphNode,
|
||||
) error {
|
||||
edges := idx.outgoing[condID]
|
||||
if len(edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
branchID := branchNodeID(condID)
|
||||
wf.AddPassthroughNode(branchID).AddInput(condID)
|
||||
|
||||
endNodes := map[string]bool{compose.END: true}
|
||||
for _, edge := range edges {
|
||||
endNodes[edge.Target] = true
|
||||
}
|
||||
|
||||
sortedEdges := append([]graphEdge(nil), edges...)
|
||||
sortEdgesByCanvas(sortedEdges, idx.nodes)
|
||||
|
||||
branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) {
|
||||
rt := workflowRuntimeFrom(runCtx)
|
||||
if rt == nil {
|
||||
return compose.END, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
emitConditionBranchProgress(rt.args, rt.runID, condNode, sortedEdges, idx.nodes, rt.state)
|
||||
for edgeIdx, edge := range sortedEdges {
|
||||
if conditionBranchAllowed(edge, edgeIdx, rt.state) {
|
||||
return edge.Target, nil
|
||||
}
|
||||
}
|
||||
return compose.END, nil
|
||||
}, endNodes)
|
||||
wf.AddBranch(branchID, branch)
|
||||
|
||||
for _, edge := range edges {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(branchID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func wireEdgeConditionBranch(
|
||||
wf *compose.Workflow[WorkflowInput, WorkflowOutput],
|
||||
nodeRefs map[string]*compose.WorkflowNode,
|
||||
idx *graphIndex,
|
||||
sourceID string,
|
||||
sourceNode graphNode,
|
||||
) error {
|
||||
edges := idx.outgoing[sourceID]
|
||||
if len(edges) == 0 {
|
||||
return nil
|
||||
}
|
||||
branchID := edgeBranchNodeID(sourceID)
|
||||
wf.AddPassthroughNode(branchID).AddInput(sourceID)
|
||||
|
||||
endNodes := map[string]bool{compose.END: true}
|
||||
for _, edge := range edges {
|
||||
endNodes[edge.Target] = true
|
||||
}
|
||||
|
||||
sortedEdges := append([]graphEdge(nil), edges...)
|
||||
sortEdgesByCanvas(sortedEdges, idx.nodes)
|
||||
|
||||
branch := compose.NewGraphBranch(func(runCtx context.Context, _ map[string]any) (string, error) {
|
||||
rt := workflowRuntimeFrom(runCtx)
|
||||
if rt == nil {
|
||||
return compose.END, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
for edgeIdx, edge := range sortedEdges {
|
||||
if edgeAllowed(edge, sourceNode, edgeIdx, rt.state) {
|
||||
return edge.Target, nil
|
||||
}
|
||||
}
|
||||
return compose.END, nil
|
||||
}, endNodes)
|
||||
wf.AddBranch(branchID, branch)
|
||||
|
||||
for _, edge := range edges {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(branchID)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/einoobserve"
|
||||
)
|
||||
|
||||
func attachWorkflowCallbacks(ctx context.Context, cfg *config.Config, args RunArgs, workflowName string) context.Context {
|
||||
if cfg == nil {
|
||||
return ctx
|
||||
}
|
||||
cbCfg := &cfg.MultiAgent.EinoCallbacks
|
||||
return einoobserve.AttachAgentRunCallbacks(ctx, cbCfg, einoobserve.Params{
|
||||
Logger: args.Logger,
|
||||
Progress: args.Progress,
|
||||
ConversationID: args.ConversationID,
|
||||
OrchMode: "workflow",
|
||||
OrchestratorName: workflowName,
|
||||
})
|
||||
}
|
||||
@@ -1,243 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
func executeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState) error {
|
||||
_, err := invokeEinoGraph(ctx, args, runID, workflowID, version, g, state, false)
|
||||
return err
|
||||
}
|
||||
|
||||
func invokeEinoGraph(ctx context.Context, args RunArgs, runID string, workflowID string, version int, g *graphDef, state *WorkflowLocalState, resume bool) (bool, error) {
|
||||
wfInput := workflowInputFromMap(state.Inputs)
|
||||
if resume {
|
||||
wfInput = WorkflowInput{}
|
||||
}
|
||||
rt := &workflowRuntime{
|
||||
args: args,
|
||||
runID: runID,
|
||||
idx: indexGraph(g),
|
||||
state: state,
|
||||
}
|
||||
|
||||
art, err := defaultEngine.getOrCompile(ctx, workflowID, version, g)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("编译 Eino Workflow 失败: %w", err)
|
||||
}
|
||||
rt.idx = art.idx
|
||||
|
||||
runCtx := withWorkflowRuntime(ctx, rt)
|
||||
runCtx = attachWorkflowCallbacks(runCtx, args.AppCfg, args, workflowID)
|
||||
|
||||
invokeOpts := []compose.Option{compose.WithCheckPointID(runID)}
|
||||
for {
|
||||
_, err = art.runnable.Invoke(runCtx, wfInput, invokeOpts...)
|
||||
if err == nil {
|
||||
return false, nil
|
||||
}
|
||||
if hitlErr := extractAwaitingHITL(err, art, runID, args, state); hitlErr != nil {
|
||||
return true, hitlErr
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
func extractAwaitingHITL(err error, art *compiledArtifact, runID string, args RunArgs, state *WorkflowLocalState) error {
|
||||
info, ok := compose.ExtractInterruptInfo(err)
|
||||
if !ok || len(art.hitlIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
nodeID := nextHITLNodeID(info, art.hitlIDs)
|
||||
node := art.idx.nodes[nodeID]
|
||||
if nodeID == "" {
|
||||
return nil
|
||||
}
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
label := firstNonEmpty(node.Label, nodeID)
|
||||
if args.DB != nil {
|
||||
pending := map[string]any{
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"checkpointId": runID,
|
||||
"interrupt": workflowInterruptMetadata(info),
|
||||
"resumePayload": map[string]any{"approved": "bool", "comment": "string"},
|
||||
}
|
||||
pendingJSON, _ := json.Marshal(pending)
|
||||
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, nodeID, string(pendingJSON))
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_waiting", fmt.Sprintf("等待人工确认:%s", label), map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": nodeID,
|
||||
"label": label,
|
||||
"prompt": prompt,
|
||||
"reviewer": cfgString(node.Config, "reviewer"),
|
||||
"mode": "interactive",
|
||||
"resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID),
|
||||
})
|
||||
}
|
||||
return &AwaitingHITLError{
|
||||
RunID: runID,
|
||||
NodeID: nodeID,
|
||||
NodeLabel: label,
|
||||
Prompt: prompt,
|
||||
Reviewer: cfgString(node.Config, "reviewer"),
|
||||
}
|
||||
}
|
||||
|
||||
func workflowInterruptMetadata(info *compose.InterruptInfo) map[string]any {
|
||||
if info == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
before := append([]string(nil), info.BeforeNodes...)
|
||||
return map[string]any{
|
||||
"beforeNodes": before,
|
||||
"resumeTarget": firstString(before),
|
||||
"address": map[string]any{
|
||||
"kind": "compose_interrupt",
|
||||
"beforeNodes": before,
|
||||
"path": strings.Join(before, "/"),
|
||||
},
|
||||
"raw": fmt.Sprintf("%+v", info),
|
||||
}
|
||||
}
|
||||
|
||||
func firstString(values []string) string {
|
||||
if len(values) == 0 {
|
||||
return ""
|
||||
}
|
||||
return values[0]
|
||||
}
|
||||
|
||||
func nextHITLNodeID(info *compose.InterruptInfo, hitlIDs []string) string {
|
||||
if info != nil && len(info.BeforeNodes) > 0 {
|
||||
for _, id := range info.BeforeNodes {
|
||||
for _, hitl := range hitlIDs {
|
||||
if id == hitl {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
return info.BeforeNodes[0]
|
||||
}
|
||||
if len(hitlIDs) == 0 {
|
||||
return ""
|
||||
}
|
||||
return hitlIDs[0]
|
||||
}
|
||||
|
||||
// ResumeWorkflowRun continues a run paused at HITL after human decision.
|
||||
func ResumeWorkflowRun(ctx context.Context, args RunArgs, runID string, approved bool, comment string) (*RunResult, error) {
|
||||
run, err := args.DB.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if run == nil {
|
||||
return nil, fmt.Errorf("工作流运行不存在")
|
||||
}
|
||||
if run.Status != "awaiting_hitl" {
|
||||
return nil, fmt.Errorf("工作流运行不在等待审批状态: %s", run.Status)
|
||||
}
|
||||
wf, err := args.DB.GetWorkflowDefinition(run.WorkflowID)
|
||||
if err != nil || wf == nil {
|
||||
return nil, fmt.Errorf("工作流定义不存在")
|
||||
}
|
||||
graph, err := parseGraph(wf.GraphJSON)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var input map[string]interface{}
|
||||
_ = json.Unmarshal([]byte(run.InputJSON), &input)
|
||||
state := newWorkflowLocalState(input, runID)
|
||||
if state.Inputs == nil {
|
||||
state.Inputs = map[string]any{}
|
||||
}
|
||||
state.Inputs["_hitl_approved"] = approved
|
||||
state.Inputs["_hitl_comment"] = strings.TrimSpace(comment)
|
||||
state.Inputs["_hitl_node_id"] = run.PendingHITLNodeID
|
||||
|
||||
if !approved {
|
||||
errText := strings.TrimSpace(comment)
|
||||
if errText == "" {
|
||||
errText = "人工审批拒绝"
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText)
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_rejected", fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": run.PendingHITLNodeID,
|
||||
"comment": errText,
|
||||
})
|
||||
}
|
||||
return &RunResult{
|
||||
RunID: runID,
|
||||
Response: fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", run.PendingHITLNodeID),
|
||||
Status: "rejected",
|
||||
}, nil
|
||||
}
|
||||
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": run.PendingHITLNodeID,
|
||||
"comment": strings.TrimSpace(comment),
|
||||
})
|
||||
}
|
||||
|
||||
_ = args.DB.SetWorkflowRunStatus(runID, "running")
|
||||
resumeArgs := args
|
||||
if strings.TrimSpace(resumeArgs.ConversationID) == "" {
|
||||
resumeArgs.ConversationID = run.ConversationID
|
||||
}
|
||||
|
||||
awaiting, err := invokeEinoGraph(ctx, resumeArgs, runID, wf.ID, run.WorkflowVersion, graph, state, true)
|
||||
if err != nil {
|
||||
if IsAwaitingHITL(err) {
|
||||
return &RunResult{
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
Response: fmt.Sprintf("工作流在节点「%s」等待下一次人工确认。", err.(*AwaitingHITLError).NodeID),
|
||||
AwaitingHITL: true,
|
||||
}, nil
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
_ = awaiting
|
||||
|
||||
output := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state)
|
||||
_ = args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), "")
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil
|
||||
}
|
||||
@@ -1,412 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func testWorkflowDB(t *testing.T) *database.DB {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(dir, "workflow.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func linearStartOutputGraph() string {
|
||||
return `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 120}, "config": {"output_key": "result", "source_binding": {"from": "inputs", "field": "message"}}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
],
|
||||
"config": {"schema_version": 1}
|
||||
}`
|
||||
}
|
||||
|
||||
func conditionBranchGraph() string {
|
||||
return `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "cond-1", "type": "condition", "label": "判断", "position": {"x": 0, "y": 80}, "config": {"expression": "{{inputs.message}} == yes"}},
|
||||
{"id": "out-yes", "type": "output", "label": "是", "position": {"x": -80, "y": 160}, "config": {"output_key": "branch", "static_value": "yes"}},
|
||||
{"id": "out-no", "type": "output", "label": "否", "position": {"x": 80, "y": 160}, "config": {"output_key": "branch", "static_value": "no"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "cond-1"},
|
||||
{"id": "e2", "source": "cond-1", "target": "out-yes", "label": "是"},
|
||||
{"id": "e3", "source": "cond-1", "target": "out-no", "label": "否"}
|
||||
],
|
||||
"config": {"schema_version": 1}
|
||||
}`
|
||||
}
|
||||
|
||||
func TestValidateGraphJSON_linear(t *testing.T) {
|
||||
if err := ValidateGraphJSON(context.Background(), linearStartOutputGraph()); err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateGraphJSON_rejectsInvalidGraphs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
graph string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "start with incoming edge",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "start-1"},
|
||||
{"id": "e3", "source": "agent-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "开始节点",
|
||||
},
|
||||
{
|
||||
name: "output with outgoing edge",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
|
||||
{"id": "end-1", "type": "end", "label": "结束", "position": {"x": 0, "y": 160}, "config": {}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"},
|
||||
{"id": "e2", "source": "out-1", "target": "end-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "不能有出边",
|
||||
},
|
||||
{
|
||||
name: "tool without name",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "tool-1", "type": "tool", "label": "工具", "position": {"x": 0, "y": 80}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "tool-1"},
|
||||
{"id": "e2", "source": "tool-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "必须选择 MCP 工具",
|
||||
},
|
||||
{
|
||||
name: "condition with too many branches",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "cond-1", "type": "condition", "label": "判断", "position": {"x": 0, "y": 80}, "config": {"expression": "{{inputs.message}}"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出1", "position": {"x": -80, "y": 160}, "config": {"output_key": "a"}},
|
||||
{"id": "out-2", "type": "output", "label": "输出2", "position": {"x": 0, "y": 160}, "config": {"output_key": "b"}},
|
||||
{"id": "out-3", "type": "output", "label": "输出3", "position": {"x": 80, "y": 160}, "config": {"output_key": "c"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "cond-1"},
|
||||
{"id": "e2", "source": "cond-1", "target": "out-1"},
|
||||
{"id": "e3", "source": "cond-1", "target": "out-2"},
|
||||
{"id": "e4", "source": "cond-1", "target": "out-3"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "1 到 2 条出边",
|
||||
},
|
||||
{
|
||||
name: "orphan node",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {"output_key": "result"}},
|
||||
{"id": "agent-1", "type": "agent", "label": "孤岛", "position": {"x": 200, "y": 80}, "config": {"instruction": "noop"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "不可达",
|
||||
},
|
||||
{
|
||||
name: "cycle",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent1", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "a1"}},
|
||||
{"id": "agent-2", "type": "agent", "label": "Agent2", "position": {"x": 0, "y": 160}, "config": {"instruction": "noop", "output_key": "a2"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 240}, "config": {"output_key": "result"}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "agent-2"},
|
||||
{"id": "e3", "source": "agent-2", "target": "agent-1"},
|
||||
{"id": "e4", "source": "agent-2", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "环路",
|
||||
},
|
||||
{
|
||||
name: "output without key",
|
||||
graph: `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 80}, "config": {}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "out-1"}
|
||||
]
|
||||
}`,
|
||||
wantErr: "输出变量名",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateGraphJSON(context.Background(), tt.graph)
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("error = %q, want substring %q", err.Error(), tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompileEngine_linear(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := defaultEngine.compile(ctx, g); err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func createTestWorkflowRun(t *testing.T, db *database.DB, runID string) {
|
||||
t.Helper()
|
||||
if err := db.CreateWorkflowRun(&database.WorkflowRun{
|
||||
ID: runID,
|
||||
WorkflowID: "test-wf",
|
||||
Status: "running",
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateWorkflowRun: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEinoGraph_linearStartOutput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
createTestWorkflowRun(t, db, "run-linear")
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-linear")
|
||||
args := RunArgs{DB: db}
|
||||
if err := executeEinoGraph(ctx, args, "run-linear", "test-wf", 1, g, state); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if got := state.Outputs["result"]; got != "ping" {
|
||||
t.Fatalf("outputs[result] = %v, want ping", got)
|
||||
}
|
||||
if len(state.Executed) != 2 {
|
||||
t.Fatalf("executed nodes = %d, want 2", len(state.Executed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEinoGraph_checkpointRestoresStartOutput(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
checkpointStore, err := newFileCheckPointStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("new checkpoint store: %v", err)
|
||||
}
|
||||
state := newWorkflowLocalState(map[string]interface{}{"message": "ping"}, "run-checkpoint")
|
||||
node := graphNode{ID: "start-1", Type: "start"}
|
||||
wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput](
|
||||
compose.WithGenLocalState(func(context.Context) *WorkflowLocalState { return state }),
|
||||
)
|
||||
start := wf.AddLambdaNode("start-1", compose.InvokableLambda(func(_ context.Context, input WorkflowInput) (WorkflowNodeOutput, error) {
|
||||
result := startOutputMap(node, input.Message, input.ConversationID, input.ProjectID)
|
||||
state.NodeOutputs[node.ID] = result
|
||||
state.NodeOutputs["condition-1"] = conditionOutputMap(graphNode{ID: "condition-1", Type: "condition"}, "{{inputs.message}} == ping", true)
|
||||
state.NodeOutputs["tool-1"] = toolOutputMap(graphNode{ID: "tool-1", Type: "tool"}, "tool result", "lookup", map[string]any{"id": "1"}, "exec-1", false)
|
||||
state.NodeOutputs["agent-1"] = agentOutputMap(graphNode{ID: "agent-1", Type: "agent"}, "agent result", "chat", []string{"exec-1"})
|
||||
state.NodeOutputs["hitl-1"] = hitlOutputMap(graphNode{ID: "hitl-1", Type: "hitl"}, "completed", "approved", "continue?", "reviewer", true)
|
||||
state.NodeOutputs["output-1"] = outputNodeOutputMap(graphNode{ID: "output-1", Type: "output"}, "result", "ping")
|
||||
state.NodeOutputs["end-1"] = endOutputMap(graphNode{ID: "end-1", Type: "end"}, "done")
|
||||
state.LastOutput = result
|
||||
state.Outputs["seed"] = "preserved"
|
||||
return result, nil
|
||||
}))
|
||||
outputNode := wf.AddLambdaNode("out-1", compose.InvokableLambda(func(_ context.Context, input WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
return input, nil
|
||||
}))
|
||||
start.AddInput(compose.START)
|
||||
outputNode.AddInput("start-1")
|
||||
wf.End().AddInput("out-1", compose.ToField("out-1"))
|
||||
runnable, err := wf.Compile(ctx,
|
||||
compose.WithCheckPointStore(checkpointStore),
|
||||
compose.WithInterruptAfterNodes([]string{"start-1"}),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("compile: %v", err)
|
||||
}
|
||||
|
||||
_, err = runnable.Invoke(ctx, workflowInputFromMap(state.Inputs), compose.WithCheckPointID("run-checkpoint"))
|
||||
info, ok := compose.ExtractInterruptInfo(err)
|
||||
if !ok {
|
||||
t.Fatalf("invoke error = %v, want checkpoint interrupt", err)
|
||||
}
|
||||
restored, ok := info.State.(*WorkflowLocalState)
|
||||
if !ok {
|
||||
t.Fatalf("checkpoint state = %T, want *WorkflowLocalState", info.State)
|
||||
}
|
||||
for nodeID, wantType := range map[string]string{
|
||||
"start-1": "StartOutput",
|
||||
"condition-1": "ConditionOutput",
|
||||
"tool-1": "ToolOutput",
|
||||
"agent-1": "AgentOutput",
|
||||
"hitl-1": "HITLOutput",
|
||||
"output-1": "OutputNodeOutput",
|
||||
"end-1": "NodeOutputEnvelope",
|
||||
} {
|
||||
if got := fmt.Sprintf("%T", restored.NodeOutputs[nodeID]["typed"]); got != "workflow."+wantType {
|
||||
t.Fatalf("restored %s typed output = %s, want workflow.%s", nodeID, got, wantType)
|
||||
}
|
||||
}
|
||||
if got := valueFromPath("previous.message", restored); got != "ping" {
|
||||
t.Fatalf("restored previous.message = %v, want ping", got)
|
||||
}
|
||||
if got := valueFromPath("inputs.message", restored); got != "ping" {
|
||||
t.Fatalf("restored inputs.message = %v, want ping", got)
|
||||
}
|
||||
if got := valueFromPath("outputs.seed", restored); got != "preserved" {
|
||||
t.Fatalf("restored outputs.seed = %v, want preserved", got)
|
||||
}
|
||||
|
||||
result, err := runnable.Invoke(ctx, WorkflowInput{}, compose.WithCheckPointID("run-checkpoint"))
|
||||
if err != nil {
|
||||
t.Fatalf("resume checkpoint: %v", err)
|
||||
}
|
||||
output, ok := result["out-1"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("resumed output type = %T, want map[string]any", result["out-1"])
|
||||
}
|
||||
if got := output["output"]; got != "ping" {
|
||||
t.Fatalf("resumed output = %v, want ping", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteEinoGraph_conditionBranch(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
createTestWorkflowRun(t, db, "run-yes")
|
||||
createTestWorkflowRun(t, db, "run-no")
|
||||
g, err := parseGraph(conditionBranchGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
stateYes := newWorkflowLocalState(map[string]interface{}{"message": "yes"}, "run-yes")
|
||||
if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-yes", "test-wf-branch", 1, g, stateYes); err != nil {
|
||||
t.Fatalf("execute yes: %v", err)
|
||||
}
|
||||
if got := stateYes.Outputs["branch"]; got != "yes" {
|
||||
t.Fatalf("yes branch output = %v", got)
|
||||
}
|
||||
|
||||
stateNo := newWorkflowLocalState(map[string]interface{}{"message": "no"}, "run-no")
|
||||
if err := executeEinoGraph(ctx, RunArgs{DB: db}, "run-no", "test-wf-branch", 1, g, stateNo); err != nil {
|
||||
t.Fatalf("execute no: %v", err)
|
||||
}
|
||||
if got := stateNo.Outputs["branch"]; got != "no" {
|
||||
t.Fatalf("no branch output = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRoleBoundWorkflow_integration(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
db := testWorkflowDB(t)
|
||||
graph := linearStartOutputGraph()
|
||||
if err := db.UpsertWorkflowDefinition(&database.WorkflowDefinition{
|
||||
ID: "wf-linear",
|
||||
Name: "线性流程",
|
||||
Version: 1,
|
||||
GraphJSON: graph,
|
||||
Enabled: true,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
role := config.RoleConfig{
|
||||
Name: "tester",
|
||||
Enabled: true,
|
||||
WorkflowID: "wf-linear",
|
||||
WorkflowPolicy: "auto",
|
||||
}
|
||||
result, err := RunRoleBoundWorkflow(ctx, RunArgs{
|
||||
DB: db,
|
||||
Logger: zap.NewNop(),
|
||||
Role: role,
|
||||
UserMessage: "from-role",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("RunRoleBoundWorkflow: %v", err)
|
||||
}
|
||||
if result == nil || result.RunID == "" {
|
||||
t.Fatal("expected run result")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompiledCache_reuse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
SetCheckpointDir(t.TempDir())
|
||||
InvalidateCompiledCache("cache-wf")
|
||||
g, err := parseGraph(linearStartOutputGraph())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a1, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a2, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a1 != a2 {
|
||||
t.Fatal("expected cached artifact pointer reuse")
|
||||
}
|
||||
InvalidateCompiledCache("cache-wf")
|
||||
a3, err := defaultEngine.getOrCompile(ctx, "cache-wf", 1, g)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a1 == a3 {
|
||||
t.Fatal("expected new artifact after invalidation")
|
||||
}
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type workflowRuntimeCtxKey struct{}
|
||||
|
||||
// workflowRuntime carries per-run execution context into Eino Workflow local state.
|
||||
type workflowRuntime struct {
|
||||
args RunArgs
|
||||
runID string
|
||||
idx *graphIndex
|
||||
state *WorkflowLocalState
|
||||
}
|
||||
|
||||
func withWorkflowRuntime(ctx context.Context, rt *workflowRuntime) context.Context {
|
||||
return context.WithValue(ctx, workflowRuntimeCtxKey{}, rt)
|
||||
}
|
||||
|
||||
func workflowRuntimeFrom(ctx context.Context) *workflowRuntime {
|
||||
rt, _ := ctx.Value(workflowRuntimeCtxKey{}).(*workflowRuntime)
|
||||
return rt
|
||||
}
|
||||
|
||||
func newWorkflowRuntime(args RunArgs, runID string, idx *graphIndex, inputs map[string]interface{}) *workflowRuntime {
|
||||
return &workflowRuntime{
|
||||
args: args,
|
||||
runID: runID,
|
||||
idx: idx,
|
||||
state: newWorkflowLocalState(inputs, runID),
|
||||
}
|
||||
}
|
||||
|
||||
// RunArgs is the execution context for a role-bound workflow run.
|
||||
type RunArgs struct {
|
||||
DB *database.DB
|
||||
Logger *zap.Logger
|
||||
Role config.RoleConfig
|
||||
AppCfg *config.Config
|
||||
Agent *agent.Agent
|
||||
ConversationID string
|
||||
ProjectID string
|
||||
UserMessage string
|
||||
History []agent.ChatMessage
|
||||
RoleTools []string
|
||||
AgentsMarkdownDir string
|
||||
SystemPromptExtra string
|
||||
AssistantMessageID string
|
||||
Progress agent.ProgressCallback
|
||||
}
|
||||
|
||||
type RunResult struct {
|
||||
Response string
|
||||
RunID string
|
||||
Status string
|
||||
AwaitingHITL bool
|
||||
}
|
||||
@@ -1,239 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/cloudwego/eino/compose"
|
||||
)
|
||||
|
||||
type compiledArtifact struct {
|
||||
runnable compose.Runnable[WorkflowInput, WorkflowOutput]
|
||||
idx *graphIndex
|
||||
hitlIDs []string
|
||||
}
|
||||
|
||||
// Engine compiles and caches Eino Workflow artifacts.
|
||||
type Engine struct {
|
||||
mu sync.RWMutex
|
||||
cache map[string]*compiledArtifact
|
||||
cpStore compose.CheckPointStore
|
||||
cpStoreMu sync.Once
|
||||
cpStoreErr error
|
||||
checkpointDir string
|
||||
}
|
||||
|
||||
var defaultEngine = &Engine{
|
||||
cache: make(map[string]*compiledArtifact),
|
||||
checkpointDir: "data/workflow-checkpoints",
|
||||
}
|
||||
|
||||
// SetCheckpointDir overrides the workflow checkpoint root (mainly for tests).
|
||||
func SetCheckpointDir(dir string) {
|
||||
defaultEngine.mu.Lock()
|
||||
defer defaultEngine.mu.Unlock()
|
||||
defaultEngine.checkpointDir = strings.TrimSpace(dir)
|
||||
defaultEngine.cpStore = nil
|
||||
defaultEngine.cpStoreErr = nil
|
||||
defaultEngine.cpStoreMu = sync.Once{}
|
||||
}
|
||||
|
||||
func (e *Engine) checkpointStore() (compose.CheckPointStore, error) {
|
||||
e.cpStoreMu.Do(func() {
|
||||
e.cpStore, e.cpStoreErr = newFileCheckPointStore(e.checkpointDir)
|
||||
})
|
||||
return e.cpStore, e.cpStoreErr
|
||||
}
|
||||
|
||||
// InvalidateCompiledCache drops cached compilations for a workflow id.
|
||||
func InvalidateCompiledCache(workflowID string) {
|
||||
workflowID = strings.TrimSpace(workflowID)
|
||||
if workflowID == "" {
|
||||
return
|
||||
}
|
||||
defaultEngine.mu.Lock()
|
||||
defer defaultEngine.mu.Unlock()
|
||||
for key := range defaultEngine.cache {
|
||||
if strings.HasPrefix(key, workflowID+":") {
|
||||
delete(defaultEngine.cache, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateGraphJSON parses and trial-compiles a canvas graph (save-time gate).
|
||||
func ValidateGraphJSON(ctx context.Context, graphJSON string) error {
|
||||
g, err := parseGraph(graphJSON)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = defaultEngine.compile(ctx, g)
|
||||
return err
|
||||
}
|
||||
|
||||
func hasTerminalNode(idx *graphIndex) bool {
|
||||
for id, node := range idx.nodes {
|
||||
if len(idx.outgoing[id]) == 0 {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(node.Type, "end") || strings.EqualFold(node.Type, "output") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Engine) getOrCompile(ctx context.Context, workflowID string, version int, g *graphDef) (*compiledArtifact, error) {
|
||||
key := cacheKey(workflowID, version)
|
||||
e.mu.RLock()
|
||||
if art, ok := e.cache[key]; ok {
|
||||
e.mu.RUnlock()
|
||||
return art, nil
|
||||
}
|
||||
e.mu.RUnlock()
|
||||
|
||||
art, err := e.compile(ctx, g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.mu.Lock()
|
||||
if existing, ok := e.cache[key]; ok {
|
||||
e.mu.Unlock()
|
||||
return existing, nil
|
||||
}
|
||||
e.cache[key] = art
|
||||
e.mu.Unlock()
|
||||
return art, nil
|
||||
}
|
||||
|
||||
func (e *Engine) compile(ctx context.Context, g *graphDef) (*compiledArtifact, error) {
|
||||
cpStore, err := e.checkpointStore()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
idx := indexGraph(g)
|
||||
if err := validateGraphDefinition(g, idx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hitlIDs := collectHITLNodeIDs(idx)
|
||||
compileOpts := []compose.GraphCompileOption{
|
||||
compose.WithGraphName("CyberStrikeWorkflow"),
|
||||
compose.WithCheckPointStore(cpStore),
|
||||
}
|
||||
if len(hitlIDs) > 0 {
|
||||
compileOpts = append(compileOpts, compose.WithInterruptBeforeNodes(hitlIDs))
|
||||
}
|
||||
|
||||
wf := compose.NewWorkflow[WorkflowInput, WorkflowOutput](
|
||||
compose.WithGenLocalState(func(runCtx context.Context) *WorkflowLocalState {
|
||||
if rt := workflowRuntimeFrom(runCtx); rt != nil && rt.state != nil {
|
||||
return rt.state
|
||||
}
|
||||
return &WorkflowLocalState{
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
nodeRefs := make(map[string]*compose.WorkflowNode, len(idx.nodes))
|
||||
for id, node := range idx.nodes {
|
||||
n := node
|
||||
if strings.EqualFold(n.Type, "agent") {
|
||||
sub, err := compileAgentSubgraph(ctx, n)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编译 Agent 子图 %s 失败: %w", id, err)
|
||||
}
|
||||
nodeRefs[id] = wf.AddGraphNode(id, sub)
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(n.Type, "start") {
|
||||
nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowInput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
continue
|
||||
}
|
||||
nodeRefs[id] = wf.AddLambdaNode(id, compose.InvokableLambda(func(runCtx context.Context, _ WorkflowNodeOutput) (WorkflowNodeOutput, error) {
|
||||
return runWorkflowNodeLambda(runCtx, n)
|
||||
}))
|
||||
}
|
||||
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "condition") {
|
||||
if err := wireConditionBranch(wf, nodeRefs, idx, id, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
if hasConditionalOutgoingEdges(idx, id) {
|
||||
if err := wireEdgeConditionBranch(wf, nodeRefs, idx, id, node); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for _, edge := range idx.outgoing[id] {
|
||||
if target, ok := nodeRefs[edge.Target]; ok {
|
||||
target.AddInput(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, startID := range findStartNodeIDs(idx) {
|
||||
if ref, ok := nodeRefs[startID]; ok {
|
||||
ref.AddInput(compose.START)
|
||||
}
|
||||
}
|
||||
|
||||
endNode := wf.End()
|
||||
for id, node := range idx.nodes {
|
||||
if len(idx.outgoing[id]) == 0 || strings.EqualFold(node.Type, "end") {
|
||||
endNode.AddInput(id, compose.ToField(id))
|
||||
}
|
||||
}
|
||||
|
||||
runnable, err := wf.Compile(ctx, compileOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &compiledArtifact{runnable: runnable, idx: idx, hitlIDs: hitlIDs}, nil
|
||||
}
|
||||
|
||||
func collectHITLNodeIDs(idx *graphIndex) []string {
|
||||
var ids []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "hitl") {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func runWorkflowNodeLambda(runCtx context.Context, n graphNode) (WorkflowNodeOutput, error) {
|
||||
localRT := workflowRuntimeFrom(runCtx)
|
||||
if localRT == nil {
|
||||
return nil, fmt.Errorf("workflow runtime missing in context")
|
||||
}
|
||||
if err := prepareNodeInputState(localRT, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result, proceed, err := executeNode(runCtx, localRT.args, localRT.runID, n, localRT.state)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
localRT.state.NodeOutputs[n.ID] = result
|
||||
localRT.state.LastOutput = result
|
||||
if !proceed && !strings.EqualFold(n.Type, "end") {
|
||||
label := firstNonEmpty(n.Label, n.ID)
|
||||
if errText := cfgString(result, "error"); errText != "" {
|
||||
return result, fmt.Errorf("节点「%s」失败: %s", label, errText)
|
||||
}
|
||||
return result, fmt.Errorf("节点「%s」未继续执行", label)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import "errors"
|
||||
|
||||
// AwaitingHITLError indicates the workflow paused before a HITL node for human approval.
|
||||
type AwaitingHITLError struct {
|
||||
RunID string
|
||||
NodeID string
|
||||
NodeLabel string
|
||||
Prompt string
|
||||
Reviewer string
|
||||
}
|
||||
|
||||
func (e *AwaitingHITLError) Error() string {
|
||||
if e == nil {
|
||||
return "workflow awaiting human approval"
|
||||
}
|
||||
return "workflow awaiting human approval at node " + e.NodeID
|
||||
}
|
||||
|
||||
func IsAwaitingHITL(err error) bool {
|
||||
var target *AwaitingHITLError
|
||||
return errors.As(err, &target)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var expressionOps = []string{">=", "<=", "==", "!=", " contains ", " matches ", ">", "<"}
|
||||
var jsonFuncRe = regexp.MustCompile(`^(jsonpath|jq)\((.*),\s*(['"][^'"]+['"])\)$`)
|
||||
var jsonFuncFindRe = regexp.MustCompile(`(jsonpath|jq)\([^)]*\)`)
|
||||
var singleTemplateVarRe = regexp.MustCompile(`^\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}$`)
|
||||
|
||||
func validateConditionExpression(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式不能为空")
|
||||
}
|
||||
for _, part := range splitBoolExpr(expr, "||") {
|
||||
for _, atom := range splitBoolExpr(part, "&&") {
|
||||
if err := validateConditionAtom(atom); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateConditionAtom(expr string) error {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return fmt.Errorf("条件表达式存在空片段")
|
||||
}
|
||||
if strings.Count(expr, "{{") != strings.Count(expr, "}}") {
|
||||
return fmt.Errorf("条件表达式模板括号不匹配: %s", expr)
|
||||
}
|
||||
if err := validateJSONFunctions(expr); err != nil {
|
||||
return err
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, " matches "); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("matches 表达式两侧不能为空: %s", expr)
|
||||
}
|
||||
pattern := cleanComparable(resolveStaticTemplate(right))
|
||||
if _, err := regexp.Compile(pattern); err != nil {
|
||||
return fmt.Errorf("matches 正则非法: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
for _, op := range expressionOps {
|
||||
if op == " matches " {
|
||||
continue
|
||||
}
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
|
||||
return fmt.Errorf("表达式 %q 两侧不能为空: %s", strings.TrimSpace(op), expr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func evalCondition(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
if expr == "" {
|
||||
return true
|
||||
}
|
||||
orParts := splitBoolExpr(expr, "||")
|
||||
for _, orPart := range orParts {
|
||||
andOK := true
|
||||
for _, atom := range splitBoolExpr(orPart, "&&") {
|
||||
if !evalConditionAtom(atom, state) {
|
||||
andOK = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if andOK {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func evalConditionAtom(expr string, state *WorkflowLocalState) bool {
|
||||
expr = strings.TrimSpace(expr)
|
||||
for _, op := range expressionOps {
|
||||
if left, right, ok := splitExpressionAtom(expr, op); ok {
|
||||
left = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(left, state)))
|
||||
right = strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(right, state)))
|
||||
switch strings.TrimSpace(op) {
|
||||
case "==":
|
||||
return cleanComparable(left) == cleanComparable(right)
|
||||
case "!=":
|
||||
return cleanComparable(left) != cleanComparable(right)
|
||||
case ">":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a > b })
|
||||
case ">=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a >= b })
|
||||
case "<":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a < b })
|
||||
case "<=":
|
||||
return compareNumeric(left, right, func(a, b float64) bool { return a <= b })
|
||||
case "contains":
|
||||
return strings.Contains(cleanComparable(left), cleanComparable(right))
|
||||
case "matches":
|
||||
matched, _ := regexp.MatchString(cleanComparable(right), cleanComparable(left))
|
||||
return matched
|
||||
}
|
||||
}
|
||||
}
|
||||
resolved := strings.TrimSpace(fmt.Sprint(resolveExpressionOperand(expr, state)))
|
||||
v := strings.ToLower(cleanComparable(resolved))
|
||||
return v != "" && v != "false" && v != "0" && v != "null"
|
||||
}
|
||||
|
||||
func splitBoolExpr(expr, sep string) []string {
|
||||
parts := strings.Split(expr, sep)
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if s := strings.TrimSpace(part); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return []string{strings.TrimSpace(expr)}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func splitExpressionAtom(expr, op string) (string, string, bool) {
|
||||
if strings.TrimSpace(op) == "contains" || strings.TrimSpace(op) == "matches" {
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
idx := strings.Index(expr, op)
|
||||
if idx < 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expr[:idx], expr[idx+len(op):], true
|
||||
}
|
||||
|
||||
func compareNumeric(left, right string, cmp func(float64, float64) bool) bool {
|
||||
a, errA := strconv.ParseFloat(cleanComparable(left), 64)
|
||||
b, errB := strconv.ParseFloat(cleanComparable(right), 64)
|
||||
if errA != nil || errB != nil {
|
||||
return false
|
||||
}
|
||||
return cmp(a, b)
|
||||
}
|
||||
|
||||
func resolveStaticTemplate(s string) string {
|
||||
return templateVarRe.ReplaceAllString(s, "value")
|
||||
}
|
||||
|
||||
func resolveExpressionOperand(raw string, state *WorkflowLocalState) any {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if m := jsonFuncRe.FindStringSubmatch(raw); len(m) == 4 {
|
||||
inputExpr := strings.TrimSpace(m[2])
|
||||
path := strings.Trim(m[3], `"'`)
|
||||
input := resolveExpressionOperand(inputExpr, state)
|
||||
return evalJSONPathValue(input, path)
|
||||
}
|
||||
if m := singleTemplateVarRe.FindStringSubmatch(raw); len(m) == 2 {
|
||||
return valueFromPath(m[1], state)
|
||||
}
|
||||
return resolveTemplate(raw, state)
|
||||
}
|
||||
|
||||
func validateJSONFunctions(expr string) error {
|
||||
for _, candidate := range jsonFuncFindRe.FindAllString(expr, -1) {
|
||||
candidate = strings.TrimSpace(candidate)
|
||||
m := jsonFuncRe.FindStringSubmatch(candidate)
|
||||
if len(m) != 4 {
|
||||
return fmt.Errorf("JSONPath/JQ 函数格式应为 jsonpath(value, \"$.path\") 或 jq(value, \".path\")")
|
||||
}
|
||||
if err := validateJSONPathSyntax(strings.Trim(m[3], `"'`)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalCondition_extendedOperators(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{"score": 9, "message": "status: ok"}, "run-expr")
|
||||
state.LastOutput = map[string]any{"output": "asset-123.example.com"}
|
||||
|
||||
tests := []string{
|
||||
"{{inputs.score}} >= 9",
|
||||
"{{inputs.message}} contains ok",
|
||||
"{{previous.output}} matches ^asset-[0-9]+\\.example\\.com$",
|
||||
"{{inputs.score}} > 5 && {{inputs.message}} contains status",
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalCondition_jsonPathAndJQSafeSubset(t *testing.T) {
|
||||
state := newWorkflowLocalState(map[string]interface{}{
|
||||
"payload": map[string]any{
|
||||
"risk": 9,
|
||||
"items": []any{
|
||||
map[string]any{"name": "first"},
|
||||
},
|
||||
},
|
||||
}, "run-jsonpath")
|
||||
state.LastOutput = map[string]any{"output": `{"status":"ok","score":7}`}
|
||||
|
||||
tests := []string{
|
||||
`jsonpath({{inputs.payload}}, "$.risk") >= 8`,
|
||||
`jq({{inputs.payload}}, ".items[0].name") == first`,
|
||||
`jsonpath({{previous.output}}, "$.status") == ok`,
|
||||
}
|
||||
for _, expr := range tests {
|
||||
if err := validateConditionExpression(expr); err != nil {
|
||||
t.Fatalf("validate %q: %v", expr, err)
|
||||
}
|
||||
if !evalCondition(expr, state) {
|
||||
t.Fatalf("evalCondition(%q) = false, want true", expr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_allMerge(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinAllMerge, []map[string]any{
|
||||
{"output": "a", "left": 1},
|
||||
{"output": "b", "right": 2},
|
||||
})
|
||||
if got["kind"] != "join" || got["strategy"] != JoinAllMerge {
|
||||
t.Fatalf("join metadata = %#v", got)
|
||||
}
|
||||
values, ok := got["output"].([]any)
|
||||
if !ok || len(values) != 2 || values[0] != "a" || values[1] != "b" {
|
||||
t.Fatalf("merged output = %#v", got["output"])
|
||||
}
|
||||
if got["left"] != 1 || got["right"] != 2 {
|
||||
t.Fatalf("merged fields = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeUpstreamOutputs_firstNonEmpty(t *testing.T) {
|
||||
got := mergeUpstreamOutputs(JoinFirstNonEmpty, []map[string]any{
|
||||
{"output": ""},
|
||||
{"output": "winner"},
|
||||
})
|
||||
if got["output"] != "winner" {
|
||||
t.Fatalf("output = %#v, want winner", got["output"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunGraphJSON_simulatesUnsafeNodes(t *testing.T) {
|
||||
graph := `{
|
||||
"nodes": [
|
||||
{"id": "start-1", "type": "start", "label": "开始", "position": {"x": 0, "y": 0}, "config": {}},
|
||||
{"id": "agent-1", "type": "agent", "label": "Agent", "position": {"x": 0, "y": 80}, "config": {"instruction": "noop", "output_key": "agent_result"}},
|
||||
{"id": "out-1", "type": "output", "label": "输出", "position": {"x": 0, "y": 160}, "config": {"output_key": "result", "source_binding": {"from": "outputs", "field": "agent_result"}}}
|
||||
],
|
||||
"edges": [
|
||||
{"id": "e1", "source": "start-1", "target": "agent-1"},
|
||||
{"id": "e2", "source": "agent-1", "target": "out-1"}
|
||||
]
|
||||
}`
|
||||
result, err := DryRunGraphJSON(nilContext(), graph, map[string]any{"message": "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("DryRunGraphJSON: %v", err)
|
||||
}
|
||||
if got := result.Outputs["result"]; got != "[dry-run] agent execution skipped" {
|
||||
t.Fatalf("result output = %#v", got)
|
||||
}
|
||||
if len(result.Trace) != 3 {
|
||||
t.Fatalf("trace len = %d, want 3", len(result.Trace))
|
||||
}
|
||||
}
|
||||
|
||||
func nilContext() context.Context {
|
||||
return context.Background()
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type graphDef struct {
|
||||
Nodes []graphNode `json:"nodes"`
|
||||
Edges []graphEdge `json:"edges"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Label string `json:"label"`
|
||||
Position graphPosition `json:"position"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Label string `json:"label"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
|
||||
type graphPosition struct {
|
||||
X float64 `json:"x"`
|
||||
Y float64 `json:"y"`
|
||||
}
|
||||
|
||||
type graphIndex struct {
|
||||
nodes map[string]graphNode
|
||||
outgoing map[string][]graphEdge
|
||||
incoming map[string][]graphEdge
|
||||
}
|
||||
|
||||
func parseGraph(raw string) (*graphDef, error) {
|
||||
var g graphDef
|
||||
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &g); err != nil {
|
||||
return nil, fmt.Errorf("解析工作流图失败: %w", err)
|
||||
}
|
||||
if len(g.Nodes) == 0 {
|
||||
return nil, fmt.Errorf("工作流没有节点")
|
||||
}
|
||||
if g.Config == nil {
|
||||
g.Config = make(map[string]any)
|
||||
}
|
||||
return &g, nil
|
||||
}
|
||||
|
||||
func indexGraph(g *graphDef) *graphIndex {
|
||||
idx := &graphIndex{
|
||||
nodes: make(map[string]graphNode, len(g.Nodes)),
|
||||
outgoing: make(map[string][]graphEdge),
|
||||
incoming: make(map[string][]graphEdge),
|
||||
}
|
||||
for _, node := range g.Nodes {
|
||||
node.ID = strings.TrimSpace(node.ID)
|
||||
if node.ID == "" {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(node.Type) == "" {
|
||||
node.Type = "tool"
|
||||
}
|
||||
if node.Config == nil {
|
||||
node.Config = make(map[string]any)
|
||||
}
|
||||
idx.nodes[node.ID] = node
|
||||
}
|
||||
for _, edge := range g.Edges {
|
||||
if _, ok := idx.nodes[edge.Source]; !ok {
|
||||
continue
|
||||
}
|
||||
if _, ok := idx.nodes[edge.Target]; !ok {
|
||||
continue
|
||||
}
|
||||
idx.outgoing[edge.Source] = append(idx.outgoing[edge.Source], edge)
|
||||
idx.incoming[edge.Target] = append(idx.incoming[edge.Target], edge)
|
||||
}
|
||||
for source := range idx.outgoing {
|
||||
sortEdgesByCanvas(idx.outgoing[source], idx.nodes)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
func sortEdgesByCanvas(edges []graphEdge, nodes map[string]graphNode) {
|
||||
sort.SliceStable(edges, func(i, j int) bool {
|
||||
a := nodes[edges[i].Target]
|
||||
b := nodes[edges[j].Target]
|
||||
if a.Position.Y != b.Position.Y {
|
||||
return a.Position.Y < b.Position.Y
|
||||
}
|
||||
if a.Position.X != b.Position.X {
|
||||
return a.Position.X < b.Position.X
|
||||
}
|
||||
return edges[i].Target < edges[j].Target
|
||||
})
|
||||
}
|
||||
|
||||
func sortNodeIDsByCanvas(ids []string, nodes map[string]graphNode) {
|
||||
sort.SliceStable(ids, func(i, j int) bool {
|
||||
a := nodes[ids[i]]
|
||||
b := nodes[ids[j]]
|
||||
if a.Position.Y != b.Position.Y {
|
||||
return a.Position.Y < b.Position.Y
|
||||
}
|
||||
if a.Position.X != b.Position.X {
|
||||
return a.Position.X < b.Position.X
|
||||
}
|
||||
return ids[i] < ids[j]
|
||||
})
|
||||
}
|
||||
|
||||
func findStartNodeIDs(idx *graphIndex) []string {
|
||||
var queue []string
|
||||
for id, node := range idx.nodes {
|
||||
if strings.EqualFold(node.Type, "start") {
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
if len(queue) == 0 {
|
||||
inDegree := make(map[string]int, len(idx.nodes))
|
||||
for id := range idx.nodes {
|
||||
inDegree[id] = 0
|
||||
}
|
||||
for _, edges := range idx.outgoing {
|
||||
for _, edge := range edges {
|
||||
inDegree[edge.Target]++
|
||||
}
|
||||
}
|
||||
for id, deg := range inDegree {
|
||||
if deg == 0 {
|
||||
queue = append(queue, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
sortNodeIDsByCanvas(queue, idx.nodes)
|
||||
return queue
|
||||
}
|
||||
|
||||
func branchNodeID(nodeID string) string {
|
||||
return nodeID + "__eino_branch"
|
||||
}
|
||||
|
||||
func edgeBranchNodeID(nodeID string) string {
|
||||
return nodeID + "__eino_edge_branch"
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
)
|
||||
|
||||
// HITLDecision is a human decision on a workflow approval node.
|
||||
type HITLDecision struct {
|
||||
Approved bool
|
||||
Comment string
|
||||
}
|
||||
|
||||
var hitlWaiters sync.Map // runID -> chan HITLDecision
|
||||
|
||||
func registerHITLWaiter(runID string) chan HITLDecision {
|
||||
ch := make(chan HITLDecision, 1)
|
||||
hitlWaiters.Store(runID, ch)
|
||||
return ch
|
||||
}
|
||||
|
||||
func unregisterHITLWaiter(runID string, ch chan HITLDecision) {
|
||||
hitlWaiters.CompareAndDelete(runID, ch)
|
||||
}
|
||||
|
||||
// NotifyHITLDecision wakes a streaming workflow run waiting at a HITL node.
|
||||
// Returns true when an active waiter was signaled.
|
||||
func NotifyHITLDecision(runID string, decision HITLDecision) bool {
|
||||
v, ok := hitlWaiters.Load(runID)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
ch, ok := v.(chan HITLDecision)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- decision:
|
||||
return true
|
||||
default:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func readHITLDecisionFromDB(db *database.DB, runID string) (HITLDecision, bool, error) {
|
||||
if db == nil {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
run, err := db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return HITLDecision{}, false, err
|
||||
}
|
||||
if run == nil || strings.TrimSpace(run.PendingHITLJSON) == "" {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
var pending map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(run.PendingHITLJSON), &pending); err != nil {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
raw, ok := pending["decision"]
|
||||
if !ok {
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
decision := strings.ToLower(strings.TrimSpace(fmt.Sprint(raw)))
|
||||
switch decision {
|
||||
case "approved", "approve":
|
||||
comment := ""
|
||||
if v, ok := pending["comment"]; ok {
|
||||
comment = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return HITLDecision{Approved: true, Comment: comment}, true, nil
|
||||
case "rejected", "reject":
|
||||
comment := ""
|
||||
if v, ok := pending["comment"]; ok {
|
||||
comment = strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return HITLDecision{Approved: false, Comment: comment}, true, nil
|
||||
default:
|
||||
return HITLDecision{}, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func waitWorkflowHITLDecision(ctx context.Context, db *database.DB, runID string) (HITLDecision, error) {
|
||||
ch := registerHITLWaiter(runID)
|
||||
defer unregisterHITLWaiter(runID, ch)
|
||||
return waitWorkflowHITLDecisionWithChannel(ctx, db, runID, ch)
|
||||
}
|
||||
|
||||
func waitWorkflowHITLDecisionWithChannel(ctx context.Context, db *database.DB, runID string, ch chan HITLDecision) (HITLDecision, error) {
|
||||
if d, ok, err := readHITLDecisionFromDB(db, runID); err != nil {
|
||||
return HITLDecision{}, err
|
||||
} else if ok {
|
||||
return d, nil
|
||||
}
|
||||
|
||||
ticker := time.NewTicker(500 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return HITLDecision{}, ctx.Err()
|
||||
case d := <-ch:
|
||||
return d, nil
|
||||
case <-ticker.C:
|
||||
if d, ok, err := readHITLDecisionFromDB(db, runID); err != nil {
|
||||
return HITLDecision{}, err
|
||||
} else if ok {
|
||||
return d, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
JoinAllMerge = "all_merge"
|
||||
JoinLastByCanvas = "last_by_canvas"
|
||||
JoinFirstNonEmpty = "first_non_empty"
|
||||
JoinFailFast = "fail_fast"
|
||||
)
|
||||
|
||||
var allowedJoinStrategies = map[string]bool{
|
||||
JoinAllMerge: true,
|
||||
JoinLastByCanvas: true,
|
||||
JoinFirstNonEmpty: true,
|
||||
JoinFailFast: true,
|
||||
}
|
||||
|
||||
func joinStrategy(node graphNode) string {
|
||||
strategy := strings.ToLower(cfgString(node.Config, "join_strategy"))
|
||||
if strategy == "" {
|
||||
return JoinAllMerge
|
||||
}
|
||||
return strategy
|
||||
}
|
||||
|
||||
func prepareNodeInputState(rt *workflowRuntime, node graphNode) error {
|
||||
if rt == nil || rt.idx == nil || rt.state == nil {
|
||||
return nil
|
||||
}
|
||||
incoming := rt.idx.incoming[node.ID]
|
||||
if len(incoming) <= 1 {
|
||||
return nil
|
||||
}
|
||||
strategy := joinStrategy(node)
|
||||
if !allowedJoinStrategies[strategy] {
|
||||
return fmt.Errorf("节点「%s」使用了未知汇聚策略: %s", firstNonEmpty(node.Label, node.ID), strategy)
|
||||
}
|
||||
upstreams := make([]map[string]any, 0, len(incoming))
|
||||
for _, edge := range incoming {
|
||||
out := rt.state.NodeOutputs[edge.Source]
|
||||
if out == nil {
|
||||
continue
|
||||
}
|
||||
if isFailedNodeOutput(out) && strategy == JoinFailFast {
|
||||
return fmt.Errorf("上游节点「%s」失败,汇聚策略 fail_fast 中止", edge.Source)
|
||||
}
|
||||
upstreams = append(upstreams, out)
|
||||
}
|
||||
if len(upstreams) == 0 {
|
||||
return nil
|
||||
}
|
||||
rt.state.LastOutput = mergeUpstreamOutputs(strategy, upstreams)
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeUpstreamOutputs(strategy string, upstreams []map[string]any) map[string]any {
|
||||
switch strategy {
|
||||
case JoinLastByCanvas:
|
||||
return cloneNodeOutput(upstreams[len(upstreams)-1])
|
||||
case JoinFirstNonEmpty:
|
||||
for _, out := range upstreams {
|
||||
if !isEmptyOutputValue(out["output"]) {
|
||||
return cloneNodeOutput(out)
|
||||
}
|
||||
}
|
||||
return cloneNodeOutput(upstreams[0])
|
||||
default:
|
||||
merged := map[string]any{
|
||||
"kind": "join",
|
||||
"strategy": strategy,
|
||||
"upstreams": upstreams,
|
||||
}
|
||||
values := make([]any, 0, len(upstreams))
|
||||
for _, out := range upstreams {
|
||||
values = append(values, out["output"])
|
||||
for k, v := range out {
|
||||
if _, exists := merged[k]; !exists {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
merged["output"] = values
|
||||
return merged
|
||||
}
|
||||
}
|
||||
|
||||
func cloneNodeOutput(in map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(in))
|
||||
for k, v := range in {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func isEmptyOutputValue(v any) bool {
|
||||
if v == nil {
|
||||
return true
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(v)) == ""
|
||||
}
|
||||
|
||||
func isFailedNodeOutput(out map[string]any) bool {
|
||||
if out == nil {
|
||||
return false
|
||||
}
|
||||
if v, ok := out["error"]; ok && strings.TrimSpace(fmt.Sprint(v)) != "" {
|
||||
return true
|
||||
}
|
||||
if v, ok := out["is_error"]; ok {
|
||||
return strings.EqualFold(fmt.Sprint(v), "true")
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func evalJSONPathValue(input any, path string) any {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" || path == "$" || path == "." {
|
||||
return input
|
||||
}
|
||||
if strings.HasPrefix(path, "$.") {
|
||||
path = strings.TrimPrefix(path, "$.")
|
||||
} else if strings.HasPrefix(path, ".") {
|
||||
path = strings.TrimPrefix(path, ".")
|
||||
} else if strings.HasPrefix(path, "$") {
|
||||
path = strings.TrimPrefix(path, "$")
|
||||
}
|
||||
cur := normalizeJSONInput(input)
|
||||
for _, token := range parseJSONPathTokens(path) {
|
||||
if token == "" {
|
||||
continue
|
||||
}
|
||||
switch v := cur.(type) {
|
||||
case map[string]any:
|
||||
cur = v[token]
|
||||
case []any:
|
||||
idx, err := strconv.Atoi(token)
|
||||
if err != nil || idx < 0 || idx >= len(v) {
|
||||
return ""
|
||||
}
|
||||
cur = v[idx]
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
if cur == nil {
|
||||
return ""
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func normalizeJSONInput(input any) any {
|
||||
switch v := input.(type) {
|
||||
case string:
|
||||
var decoded any
|
||||
if err := json.Unmarshal([]byte(v), &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return v
|
||||
case []byte:
|
||||
var decoded any
|
||||
if err := json.Unmarshal(v, &decoded); err == nil {
|
||||
return decoded
|
||||
}
|
||||
return string(v)
|
||||
default:
|
||||
return input
|
||||
}
|
||||
}
|
||||
|
||||
func parseJSONPathTokens(path string) []string {
|
||||
var tokens []string
|
||||
var buf strings.Builder
|
||||
for i := 0; i < len(path); i++ {
|
||||
ch := path[i]
|
||||
switch ch {
|
||||
case '.':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
case '[':
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
buf.Reset()
|
||||
}
|
||||
j := i + 1
|
||||
for j < len(path) && path[j] != ']' {
|
||||
j++
|
||||
}
|
||||
if j <= len(path) {
|
||||
token := strings.Trim(path[i+1:j], `"' `)
|
||||
tokens = append(tokens, token)
|
||||
i = j
|
||||
}
|
||||
default:
|
||||
buf.WriteByte(ch)
|
||||
}
|
||||
}
|
||||
if buf.Len() > 0 {
|
||||
tokens = append(tokens, buf.String())
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
func validateJSONPathSyntax(path string) error {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return fmt.Errorf("JSONPath 不能为空")
|
||||
}
|
||||
if !strings.HasPrefix(path, "$") && !strings.HasPrefix(path, ".") {
|
||||
return fmt.Errorf("JSONPath/JQ 路径必须以 $ 或 . 开头")
|
||||
}
|
||||
if strings.Contains(path, "..") || strings.ContainsAny(path, "*?()|") {
|
||||
return fmt.Errorf("仅支持安全路径子集,不支持通配符、递归或表达式")
|
||||
}
|
||||
if strings.Count(path, "[") != strings.Count(path, "]") {
|
||||
return fmt.Errorf("JSONPath 方括号不匹配")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func accumulateWorkflowMetric(state *WorkflowLocalState, key string, delta any) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
if state.Metrics == nil {
|
||||
state.Metrics = make(map[string]any)
|
||||
}
|
||||
current := numericMetric(state.Metrics[key])
|
||||
state.Metrics[key] = current + numericMetric(delta)
|
||||
}
|
||||
|
||||
func numericMetric(v any) float64 {
|
||||
switch n := v.(type) {
|
||||
case int:
|
||||
return float64(n)
|
||||
case int32:
|
||||
return float64(n)
|
||||
case int64:
|
||||
return float64(n)
|
||||
case float32:
|
||||
return float64(n)
|
||||
case float64:
|
||||
return n
|
||||
case string:
|
||||
f, _ := strconv.ParseFloat(n, 64)
|
||||
return f
|
||||
default:
|
||||
f, _ := strconv.ParseFloat(fmt.Sprint(v), 64)
|
||||
return f
|
||||
}
|
||||
}
|
||||
|
||||
func collectAgentMetrics(state *WorkflowLocalState, data interface{}) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || state == nil {
|
||||
return
|
||||
}
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "cost", "input_tokens", "output_tokens"} {
|
||||
if v, ok := m[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
if usage, ok := m["usage"].(map[string]interface{}); ok {
|
||||
for _, key := range []string{"prompt_tokens", "completion_tokens", "total_tokens", "input_tokens", "output_tokens"} {
|
||||
if v, ok := usage[key]; ok {
|
||||
accumulateWorkflowMetric(state, key, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func executeNode(ctx context.Context, args RunArgs, runID string, node graphNode, state *WorkflowLocalState) (map[string]any, bool, error) {
|
||||
label := node.Label
|
||||
if strings.TrimSpace(label) == "" {
|
||||
label = node.ID
|
||||
}
|
||||
nodeRunID := uuid.NewString()
|
||||
startedAt := time.Now()
|
||||
incomingCount := 0
|
||||
if rt := workflowRuntimeFrom(ctx); rt != nil && rt.idx != nil {
|
||||
incomingCount = len(rt.idx.incoming[node.ID])
|
||||
}
|
||||
input := map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"inputs": state.Inputs,
|
||||
"previous": state.LastOutput,
|
||||
"join": map[string]any{
|
||||
"strategy": joinStrategy(node),
|
||||
"incoming": incomingCount,
|
||||
},
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
if err := args.DB.CreateWorkflowNodeRun(&database.WorkflowNodeRun{
|
||||
ID: nodeRunID,
|
||||
RunID: runID,
|
||||
NodeID: node.ID,
|
||||
Status: "running",
|
||||
InputJSON: string(inputJSON),
|
||||
StartedAt: startedAt,
|
||||
}); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_node_start", fmt.Sprintf("开始节点:%s", label), map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeRunId": nodeRunID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
})
|
||||
}
|
||||
|
||||
result, proceed, status, errText := runBuiltinNode(ctx, args, node, state)
|
||||
duration := time.Since(startedAt)
|
||||
if result == nil {
|
||||
result = map[string]any{}
|
||||
}
|
||||
result["duration_ms"] = duration.Milliseconds()
|
||||
result["finished_at"] = time.Now().Format(time.RFC3339Nano)
|
||||
result["status"] = status
|
||||
accumulateWorkflowMetric(state, "node_count", 1)
|
||||
accumulateWorkflowMetric(state, "duration_ms", duration.Milliseconds())
|
||||
if strings.EqualFold(node.Type, "tool") {
|
||||
accumulateWorkflowMetric(state, "tool_call_count", 1)
|
||||
}
|
||||
outputJSON, _ := json.Marshal(result)
|
||||
if err := args.DB.FinishWorkflowNodeRun(nodeRunID, status, string(outputJSON), errText); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if status == "skipped" {
|
||||
state.Skipped = append(state.Skipped, label)
|
||||
} else {
|
||||
state.Executed = append(state.Executed, label)
|
||||
}
|
||||
if args.Progress != nil {
|
||||
progressData := map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeRunId": nodeRunID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": label,
|
||||
"status": status,
|
||||
"durationMs": duration.Milliseconds(),
|
||||
"output": result,
|
||||
}
|
||||
progressMsg := fmt.Sprintf("节点完成:%s(%s)", label, status)
|
||||
if strings.EqualFold(node.Type, "condition") {
|
||||
matched := false
|
||||
if v, ok := result["matched"].(bool); ok {
|
||||
matched = v
|
||||
}
|
||||
expr := cfgString(node.Config, "expression")
|
||||
if matched {
|
||||
progressMsg = fmt.Sprintf("条件判断:%s → 是", label)
|
||||
} else {
|
||||
progressMsg = fmt.Sprintf("条件判断:%s → 否", label)
|
||||
}
|
||||
progressData["expression"] = expr
|
||||
progressData["matched"] = matched
|
||||
}
|
||||
args.Progress("workflow_node_result", progressMsg, progressData)
|
||||
}
|
||||
state.NodeProceed[node.ID] = proceed
|
||||
return result, proceed, nil
|
||||
}
|
||||
|
||||
func emitConditionBranchProgress(args RunArgs, runID string, node graphNode, edges []graphEdge, nodes map[string]graphNode, state *WorkflowLocalState) {
|
||||
if args.Progress == nil || len(edges) == 0 {
|
||||
return
|
||||
}
|
||||
for edgeIdx, edge := range edges {
|
||||
allowed := edgeAllowed(edge, node, edgeIdx, state)
|
||||
target := nodes[edge.Target]
|
||||
targetLabel := strings.TrimSpace(target.Label)
|
||||
if targetLabel == "" {
|
||||
targetLabel = edge.Target
|
||||
}
|
||||
branchLabel := strings.TrimSpace(edge.Label)
|
||||
if branchLabel == "" {
|
||||
switch edgeIdx {
|
||||
case 0:
|
||||
branchLabel = "是"
|
||||
case 1:
|
||||
branchLabel = "否"
|
||||
default:
|
||||
branchLabel = fmt.Sprintf("分支 %d", edgeIdx+1)
|
||||
}
|
||||
}
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
eventType := "workflow_branch_skipped"
|
||||
msg := fmt.Sprintf("跳过分支「%s」→ %s", branchLabel, targetLabel)
|
||||
if allowed {
|
||||
eventType = "workflow_branch_taken"
|
||||
msg = fmt.Sprintf("执行分支「%s」→ %s", branchLabel, targetLabel)
|
||||
}
|
||||
args.Progress(eventType, msg, map[string]any{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": node.ID,
|
||||
"nodeType": node.Type,
|
||||
"label": node.Label,
|
||||
"branchLabel": branchLabel,
|
||||
"targetId": edge.Target,
|
||||
"targetLabel": targetLabel,
|
||||
"edgeCondition": cond,
|
||||
"matched": conditionMatched(state),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,333 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"cyberstrike-ai/internal/agent"
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/multiagent"
|
||||
)
|
||||
|
||||
func runBuiltinNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
cfg := node.Config
|
||||
switch strings.ToLower(strings.TrimSpace(node.Type)) {
|
||||
case "start":
|
||||
return startOutputMap(node, state.Inputs["message"], state.Inputs["conversationId"], state.Inputs["projectId"]), true, "completed", ""
|
||||
case "condition":
|
||||
expr := cfgString(cfg, "expression")
|
||||
ok := evalCondition(expr, state)
|
||||
return conditionOutputMap(node, expr, ok), true, "completed", ""
|
||||
case "output":
|
||||
key := cfgString(cfg, "output_key")
|
||||
if key == "" {
|
||||
key = "result"
|
||||
}
|
||||
var value any
|
||||
if v := cfgString(cfg, "static_value"); v != "" {
|
||||
value = v
|
||||
} else {
|
||||
value = resolveOutputSourceBinding(cfg, state)
|
||||
}
|
||||
state.Outputs[key] = value
|
||||
return outputNodeOutputMap(node, key, value), true, "completed", ""
|
||||
case "end":
|
||||
value := resolveOutputSourceBinding(cfg, state)
|
||||
if b, ok := parseFieldBinding(cfg, "result_binding"); ok {
|
||||
value = resolveBinding(b, state)
|
||||
}
|
||||
return endOutputMap(node, value), false, "completed", ""
|
||||
case "tool":
|
||||
return runToolNode(ctx, args, node, state)
|
||||
case "agent":
|
||||
return runAgentNode(ctx, args, node, state)
|
||||
case "hitl":
|
||||
return runHITLNode(args, node, state)
|
||||
default:
|
||||
reason := "未知节点类型"
|
||||
out := outputMap(envelope("unknown", node.ID, node.Type, "skipped", ""), map[string]any{"skipped": true, "reason": reason})
|
||||
return out, true, "skipped", reason
|
||||
}
|
||||
}
|
||||
|
||||
func runToolNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
toolName := cfgString(node.Config, "tool_name")
|
||||
if toolName == "" {
|
||||
errText := "工具节点未选择 MCP 工具"
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
if args.Agent == nil {
|
||||
errText := "工具节点执行失败:Agent 为空"
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
|
||||
}
|
||||
toolArgs, err := resolveToolArguments(node.Config, state)
|
||||
if err != nil {
|
||||
errText := fmt.Sprintf("工具参数不是合法 JSON:%v", err)
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "error": errText}), false, "failed", errText
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_tool_start", fmt.Sprintf("调用工具:%s", toolName), map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"tool": toolName,
|
||||
"args": toolArgs,
|
||||
})
|
||||
}
|
||||
result, err := args.Agent.ExecuteMCPToolForConversation(ctx, args.ConversationID, toolName, toolArgs)
|
||||
if err != nil {
|
||||
errText := err.Error()
|
||||
return outputMap(envelope("tool", node.ID, node.Type, "failed", ""), map[string]any{"tool_name": toolName, "arguments": toolArgs, "error": errText}), false, "failed", errText
|
||||
}
|
||||
output := ""
|
||||
executionID := ""
|
||||
isError := false
|
||||
if result != nil {
|
||||
output = result.Result
|
||||
executionID = result.ExecutionID
|
||||
isError = result.IsError
|
||||
}
|
||||
maxToolOutputBytes := config.MultiAgentEinoMiddlewareConfig{}.ReductionMaxLengthForTruncEffective()
|
||||
if args.AppCfg != nil {
|
||||
maxToolOutputBytes = args.AppCfg.MultiAgent.EinoMiddleware.ReductionMaxLengthForTruncEffective()
|
||||
}
|
||||
output = truncateWorkflowToolOutput(output, maxToolOutputBytes, executionID)
|
||||
out := toolOutputMap(node, output, toolName, toolArgs, executionID, isError)
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = output
|
||||
}
|
||||
if isError {
|
||||
errText := strings.TrimSpace(output)
|
||||
if errText == "" {
|
||||
errText = "工具返回错误"
|
||||
}
|
||||
return out, false, "failed", errText
|
||||
}
|
||||
return out, true, "completed", ""
|
||||
}
|
||||
|
||||
func truncateWorkflowToolOutput(output string, maxBytes int, executionID string) string {
|
||||
if maxBytes <= 0 || len(output) <= maxBytes {
|
||||
return output
|
||||
}
|
||||
marker := fmt.Sprintf("\n\n...[workflow tool output truncated; full result is stored in execution %s]...\n\n", strings.TrimSpace(executionID))
|
||||
if strings.TrimSpace(executionID) == "" {
|
||||
marker = "\n\n...[workflow tool output truncated; full result remains in the tool execution record]...\n\n"
|
||||
}
|
||||
budget := maxBytes - len(marker)
|
||||
if budget <= 0 {
|
||||
return marker
|
||||
}
|
||||
head := budget / 2
|
||||
tail := budget - head
|
||||
for head > 0 && !utf8.RuneStart(output[head]) {
|
||||
head--
|
||||
}
|
||||
tailStart := len(output) - tail
|
||||
for tailStart < len(output) && !utf8.RuneStart(output[tailStart]) {
|
||||
tailStart++
|
||||
}
|
||||
return output[:head] + marker + output[tailStart:]
|
||||
}
|
||||
|
||||
func runAgentNode(ctx context.Context, args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
if args.AppCfg == nil || args.Agent == nil {
|
||||
errText := "Agent 节点执行失败:应用配置或 Agent 为空"
|
||||
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"error": errText}), false, "failed", errText
|
||||
}
|
||||
mode := strings.ToLower(cfgString(node.Config, "agent_mode"))
|
||||
if mode == "" {
|
||||
mode = "eino_single"
|
||||
}
|
||||
inputSource := resolveNodeInputBinding(node.Config, state)
|
||||
message := buildAgentNodeMessage(node, state, inputSource)
|
||||
var result *multiagent.RunResult
|
||||
var err error
|
||||
state.SegmentMaxIteration = 0
|
||||
agentProgress := workflowAgentProgress(args.Progress, state, node)
|
||||
switch mode {
|
||||
case "eino_single", "single", "chat":
|
||||
result, err = multiagent.RunEinoSingleChatModelAgent(
|
||||
ctx,
|
||||
args.AppCfg,
|
||||
&args.AppCfg.MultiAgent,
|
||||
args.Agent,
|
||||
args.DB,
|
||||
args.Logger,
|
||||
args.ConversationID,
|
||||
args.ProjectID,
|
||||
message,
|
||||
args.History,
|
||||
args.RoleTools,
|
||||
agentProgress,
|
||||
nil,
|
||||
args.SystemPromptExtra,
|
||||
)
|
||||
default:
|
||||
result, err = multiagent.RunDeepAgent(
|
||||
ctx,
|
||||
args.AppCfg,
|
||||
&args.AppCfg.MultiAgent,
|
||||
args.Agent,
|
||||
args.DB,
|
||||
args.Logger,
|
||||
args.ConversationID,
|
||||
args.ProjectID,
|
||||
message,
|
||||
args.History,
|
||||
args.RoleTools,
|
||||
agentProgress,
|
||||
args.AgentsMarkdownDir,
|
||||
mode,
|
||||
nil,
|
||||
args.SystemPromptExtra,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
errText := err.Error()
|
||||
state.MainIterationOffset += state.SegmentMaxIteration
|
||||
return outputMap(envelope("agent", node.ID, node.Type, "failed", ""), map[string]any{"mode": mode, "error": errText}), false, "failed", errText
|
||||
}
|
||||
state.MainIterationOffset += state.SegmentMaxIteration
|
||||
response := ""
|
||||
mcpIDs := []string{}
|
||||
if result != nil {
|
||||
response = result.Response
|
||||
mcpIDs = result.MCPExecutionIDs
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_agent_output", response, map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"label": firstNonEmpty(node.Label, node.ID),
|
||||
"mode": mode,
|
||||
"inputSource": inputSource,
|
||||
"inputPreview": truncateWorkflowPreview(inputSource, 500),
|
||||
"mcpExecutionIds": mcpIDs,
|
||||
})
|
||||
}
|
||||
if key := cfgString(node.Config, "output_key"); key != "" {
|
||||
state.Outputs[key] = response
|
||||
}
|
||||
return agentOutputMap(node, response, mode, mcpIDs), true, "completed", ""
|
||||
}
|
||||
|
||||
func buildAgentNodeMessage(node graphNode, state *WorkflowLocalState, upstreamInput string) string {
|
||||
instruction := strings.TrimSpace(cfgString(node.Config, "instruction"))
|
||||
upstreamInput = strings.TrimSpace(upstreamInput)
|
||||
if instruction == "" {
|
||||
if upstreamInput != "" {
|
||||
return fmt.Sprintf("请基于上游节点输出继续处理:\n%s", upstreamInput)
|
||||
}
|
||||
return fmt.Sprintf("请基于上游节点输出继续处理:\n%v", state.LastOutput["output"])
|
||||
}
|
||||
if upstreamInput == "" {
|
||||
return instruction
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprintf("上游输入:\n%s\n\n节点指令:\n%s", upstreamInput, instruction))
|
||||
}
|
||||
|
||||
func workflowAgentProgress(progress agent.ProgressCallback, state *WorkflowLocalState, node graphNode) agent.ProgressCallback {
|
||||
if progress == nil {
|
||||
return nil
|
||||
}
|
||||
return func(eventType, message string, data interface{}) {
|
||||
switch eventType {
|
||||
case "response_start", "response_delta", "response", "done":
|
||||
return
|
||||
default:
|
||||
enrichWorkflowAgentEventData(data, state, node)
|
||||
collectAgentMetrics(state, data)
|
||||
if eventType == "iteration" {
|
||||
applyWorkflowMainIterationOffset(data, state)
|
||||
}
|
||||
progress(eventType, message, data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func enrichWorkflowAgentEventData(data interface{}, state *WorkflowLocalState, node graphNode) {
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
if node.ID != "" {
|
||||
m["workflowNodeId"] = node.ID
|
||||
}
|
||||
if state != nil && strings.TrimSpace(state.WorkflowRunID) != "" {
|
||||
m["workflowRunId"] = state.WorkflowRunID
|
||||
}
|
||||
}
|
||||
|
||||
func applyWorkflowMainIterationOffset(data interface{}, state *WorkflowLocalState) {
|
||||
if state == nil {
|
||||
return
|
||||
}
|
||||
m, ok := data.(map[string]interface{})
|
||||
if !ok || m == nil {
|
||||
return
|
||||
}
|
||||
scope, _ := m["einoScope"].(string)
|
||||
if strings.TrimSpace(scope) != "main" {
|
||||
return
|
||||
}
|
||||
raw := iterationNumberFromProgressData(m)
|
||||
if raw <= 0 {
|
||||
return
|
||||
}
|
||||
if raw > state.SegmentMaxIteration {
|
||||
state.SegmentMaxIteration = raw
|
||||
}
|
||||
m["iteration"] = raw + state.MainIterationOffset
|
||||
}
|
||||
|
||||
func iterationNumberFromProgressData(m map[string]interface{}) int {
|
||||
switch v := m["iteration"].(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case float32:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func runHITLNode(args RunArgs, node graphNode, state *WorkflowLocalState) (map[string]any, bool, string, string) {
|
||||
prompt := resolveHITLPromptBinding(node.Config, state)
|
||||
reviewer := cfgString(node.Config, "reviewer")
|
||||
if reviewer == "" {
|
||||
reviewer = "human"
|
||||
}
|
||||
approved := true
|
||||
if state != nil && state.Inputs != nil {
|
||||
if v, ok := state.Inputs["_hitl_approved"]; ok {
|
||||
approved = fmt.Sprint(v) == "true"
|
||||
}
|
||||
}
|
||||
if !approved {
|
||||
reason := "人工审批已拒绝"
|
||||
if state != nil && state.Inputs != nil {
|
||||
if v, ok := state.Inputs["_hitl_comment"]; ok {
|
||||
if s := strings.TrimSpace(fmt.Sprint(v)); s != "" {
|
||||
reason = s
|
||||
}
|
||||
}
|
||||
}
|
||||
return hitlOutputMap(node, "failed", "", prompt, reviewer, false), false, "failed", reason
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_checkpoint", "人工确认节点已通过", map[string]any{
|
||||
"nodeId": node.ID,
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"mode": "interactive",
|
||||
"approved": true,
|
||||
})
|
||||
}
|
||||
return hitlOutputMap(node, "completed", prompt, prompt, reviewer, true), true, "completed", ""
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Export builds a deterministic, human-readable single-workflow package.
|
||||
func Export(source Document) ([]byte, ExportMetadata, error) {
|
||||
source.ID = strings.TrimSpace(source.ID)
|
||||
source.Name = strings.TrimSpace(source.Name)
|
||||
if source.ID == "" || source.Name == "" || source.Version <= 0 || !safePackageWorkflowID(source.ID) {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow id, name and version are required")
|
||||
}
|
||||
if strings.TrimSpace(source.GraphJSON) == "" {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("workflow graph_json is required")
|
||||
}
|
||||
contentHash, graphHash, payload, err := DocumentHashes(source)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, err
|
||||
}
|
||||
workflowPath := path.Join("workflows", source.ID+".json")
|
||||
createdAt := source.UpdatedAt.UTC()
|
||||
if createdAt.IsZero() {
|
||||
createdAt = time.Unix(0, 0).UTC()
|
||||
}
|
||||
manifest := Manifest{
|
||||
PackageFormat: PackageFormat,
|
||||
FormatVersion: FormatVersion,
|
||||
PackageID: "pkg_" + strings.TrimPrefix(contentHash, "sha256:")[:16],
|
||||
CreatedAt: createdAt.Format(time.RFC3339),
|
||||
Items: []ManifestItem{{
|
||||
Type: "workflow",
|
||||
Path: workflowPath,
|
||||
SourceID: source.ID,
|
||||
SourceRevision: source.Version,
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
}},
|
||||
}
|
||||
manifestBytes, err := json.Marshal(manifest)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("marshal manifest: %w", err)
|
||||
}
|
||||
checksums := fmt.Sprintf("%s manifest.json\n%s %s\n", strings.TrimPrefix(sha256Prefixed(manifestBytes), "sha256:"), strings.TrimPrefix(contentHash, "sha256:"), workflowPath)
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, entry := range []struct {
|
||||
name string
|
||||
data []byte
|
||||
}{
|
||||
{name: "checksums.sha256", data: []byte(checksums)},
|
||||
{name: "manifest.json", data: manifestBytes},
|
||||
{name: workflowPath, data: payload},
|
||||
} {
|
||||
header := &zip.FileHeader{Name: entry.name, Method: zip.Store}
|
||||
header.SetModTime(time.Unix(0, 0).UTC())
|
||||
writer, err := zw.CreateHeader(header)
|
||||
if err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
if _, err := writer.Write(entry.data); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("write %s: %w", entry.name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, ExportMetadata{}, fmt.Errorf("close package: %w", err)
|
||||
}
|
||||
pkg := out.Bytes()
|
||||
return pkg, ExportMetadata{
|
||||
PackageHash: sha256Prefixed(pkg),
|
||||
ContentHash: contentHash,
|
||||
GraphHash: graphHash,
|
||||
SourceRevision: source.Version,
|
||||
FileName: source.ID + ".csapkg.zip",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DocumentHashes returns the canonical package item and graph hashes together
|
||||
// with the canonical item bytes used by export and inspection persistence.
|
||||
func DocumentHashes(source Document) (string, string, []byte, error) {
|
||||
graph, err := canonicalJSON([]byte(source.GraphJSON))
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("canonicalize graph_json: %w", err)
|
||||
}
|
||||
source.GraphJSON = string(graph)
|
||||
payload, err := json.Marshal(source)
|
||||
if err != nil {
|
||||
return "", "", nil, fmt.Errorf("marshal workflow payload: %w", err)
|
||||
}
|
||||
return sha256Prefixed(payload), sha256Prefixed(graph), payload, nil
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testDocument() Document {
|
||||
return Document{
|
||||
ID: "web-src-hunting",
|
||||
Name: "Web SRC 猎洞",
|
||||
Description: "面向 SRC Web 资产的侦察与漏洞候选流程",
|
||||
Version: 18,
|
||||
Enabled: true,
|
||||
GraphJSON: `{"nodes":[{"id":"start-1","type":"start","label":"开始","position":{"x":0,"y":0},"config":{}},{"id":"out-1","type":"output","label":"输出","position":{"x":0,"y":120},"config":{"output_key":"result","source_binding":{"from":"inputs","field":"message"}}}],"edges":[{"id":"e1","source":"start-1","target":"out-1"}],"config":{"schema_version":1}}`,
|
||||
UpdatedAt: time.Date(2026, 7, 13, 10, 0, 0, 0, time.UTC),
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportIsDeterministicAndSelfDescribing(t *testing.T) {
|
||||
first, firstMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("first export: %v", err)
|
||||
}
|
||||
second, secondMeta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatalf("second export: %v", err)
|
||||
}
|
||||
if !bytes.Equal(first, second) {
|
||||
t.Fatal("identical document must produce byte-identical package")
|
||||
}
|
||||
if firstMeta.PackageHash != secondMeta.PackageHash || !strings.HasPrefix(firstMeta.PackageHash, "sha256:") {
|
||||
t.Fatalf("unexpected deterministic package hash: %#v / %#v", firstMeta, secondMeta)
|
||||
}
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(first), int64(len(first)))
|
||||
if err != nil {
|
||||
t.Fatalf("open package: %v", err)
|
||||
}
|
||||
if len(zr.File) != 3 {
|
||||
t.Fatalf("zip entry count = %d, want 3", len(zr.File))
|
||||
}
|
||||
wantNames := []string{"checksums.sha256", "manifest.json", "workflows/web-src-hunting.json"}
|
||||
for i, f := range zr.File {
|
||||
if f.Name != wantNames[i] {
|
||||
t.Fatalf("entry %d = %q, want %q", i, f.Name, wantNames[i])
|
||||
}
|
||||
}
|
||||
if firstMeta.SourceRevision != 18 {
|
||||
t.Fatalf("source revision = %d, want 18", firstMeta.SourceRevision)
|
||||
}
|
||||
if !strings.HasPrefix(firstMeta.ContentHash, "sha256:") || !strings.HasPrefix(firstMeta.GraphHash, "sha256:") {
|
||||
t.Fatalf("content/graph hashes must be sha256: %#v", firstMeta)
|
||||
}
|
||||
}
|
||||
@@ -1,225 +0,0 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxArchiveBytes = 10 << 20
|
||||
MaxExtractedBytes = 20 << 20
|
||||
)
|
||||
|
||||
// PackageError contains only a contract error code and safe, client-facing fields.
|
||||
type PackageError struct {
|
||||
Code string
|
||||
Message string
|
||||
Details map[string]any
|
||||
}
|
||||
|
||||
func (e *PackageError) Error() string { return e.Code + ": " + e.Message }
|
||||
|
||||
func packageError(code, message string) error {
|
||||
return &PackageError{Code: code, Message: message}
|
||||
}
|
||||
|
||||
// ErrorCode returns a package contract code without exposing internal errors.
|
||||
func ErrorCode(err error) string {
|
||||
var target *PackageError
|
||||
if errors.As(err, &target) {
|
||||
return target.Code
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type InspectionResult struct {
|
||||
PackageHash string
|
||||
Manifest Manifest
|
||||
Document Document
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
NodeCount int
|
||||
EdgeCount int
|
||||
}
|
||||
|
||||
// InspectArchive verifies an archive without executing any package content.
|
||||
// validateGraph is injected by the application so this format package has no
|
||||
// dependency on the workflow runtime or database driver.
|
||||
func InspectArchive(ctx context.Context, archive []byte, validateGraph func(context.Context, string) error) (*InspectionResult, error) {
|
||||
if len(archive) == 0 {
|
||||
return nil, packageError("WFPKG_FILE_REQUIRED", "必须上传工作流包文件")
|
||||
}
|
||||
if len(archive) > MaxArchiveBytes {
|
||||
return nil, packageError("WFPKG_FILE_TOO_LARGE", "工作流包文件超过大小限制")
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包不是有效 ZIP 文件")
|
||||
}
|
||||
entries := make(map[string][]byte, len(zr.File))
|
||||
var extracted int64
|
||||
for _, file := range zr.File {
|
||||
if !safeArchivePath(file.Name) || file.FileInfo().IsDir() || file.FileInfo().Mode()&os.ModeSymlink != 0 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含不安全文件路径")
|
||||
}
|
||||
if _, exists := entries[file.Name]; exists {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含重复文件")
|
||||
}
|
||||
if file.UncompressedSize64 > MaxExtractedBytes || extracted+int64(file.UncompressedSize64) > MaxExtractedBytes {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
reader, err := file.Open()
|
||||
if err != nil {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "无法读取工作流包文件")
|
||||
}
|
||||
data, readErr := io.ReadAll(io.LimitReader(reader, int64(MaxExtractedBytes)-extracted+1))
|
||||
closeErr := reader.Close()
|
||||
if readErr != nil || closeErr != nil || len(data) > MaxExtractedBytes-int(extracted) {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包解压后超过大小限制")
|
||||
}
|
||||
extracted += int64(len(data))
|
||||
entries[file.Name] = data
|
||||
}
|
||||
|
||||
manifestRaw, hasManifest := entries["manifest.json"]
|
||||
checksumsRaw, hasChecksums := entries["checksums.sha256"]
|
||||
if !hasManifest || !hasChecksums {
|
||||
return nil, packageError("WFPKG_UNSUPPORTED_FORMAT", "工作流包缺少必需文件")
|
||||
}
|
||||
manifest, err := parseManifest(manifestRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(manifest.Items) != 1 || manifest.Items[0].Type != "workflow" {
|
||||
return nil, packageError("WFPKG_MULTIPLE_WORKFLOWS", "工作流包必须且只能包含一个工作流")
|
||||
}
|
||||
item := manifest.Items[0]
|
||||
workflowRaw, exists := entries[item.Path]
|
||||
if !exists || !safeWorkflowPath(item.Path) || len(entries) != 3 {
|
||||
return nil, packageError("WFPKG_INVALID_ARCHIVE", "工作流包包含未声明文件")
|
||||
}
|
||||
checksums, err := parseChecksums(checksumsRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(checksums) != 2 || checksums["manifest.json"] != sha256Prefixed(manifestRaw) || checksums[item.Path] != sha256Prefixed(workflowRaw) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和不匹配")
|
||||
}
|
||||
if item.ContentHash != sha256Prefixed(workflowRaw) || !validHash(item.ContentHash) || !validHash(item.GraphHash) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包内容校验和不匹配")
|
||||
}
|
||||
doc, err := parseDocument(workflowRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !safePackageWorkflowID(doc.ID) || doc.ID != item.SourceID || doc.Version != item.SourceRevision {
|
||||
return nil, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单与工作流内容不一致")
|
||||
}
|
||||
graph, err := canonicalJSON([]byte(doc.GraphJSON))
|
||||
if err != nil || item.GraphHash != sha256Prefixed(graph) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流图校验和不匹配")
|
||||
}
|
||||
if validateGraph == nil || validateGraph(ctx, string(graph)) != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图校验失败")
|
||||
}
|
||||
var graphShape struct {
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
Edges []json.RawMessage `json:"edges"`
|
||||
}
|
||||
if err := json.Unmarshal(graph, &graphShape); err != nil {
|
||||
return nil, packageError("WFPKG_WORKFLOW_INVALID", "工作流图不是有效 JSON")
|
||||
}
|
||||
return &InspectionResult{
|
||||
PackageHash: sha256Prefixed(archive),
|
||||
Manifest: manifest,
|
||||
Document: doc,
|
||||
ContentHash: item.ContentHash,
|
||||
GraphHash: item.GraphHash,
|
||||
NodeCount: len(graphShape.Nodes),
|
||||
EdgeCount: len(graphShape.Edges),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func safeArchivePath(name string) bool {
|
||||
return name != "" && !strings.Contains(name, `\`) && !strings.HasPrefix(name, "/") && path.Clean(name) == name && !strings.HasPrefix(name, "../") && name != ".."
|
||||
}
|
||||
|
||||
func safeWorkflowPath(name string) bool {
|
||||
rest := strings.TrimPrefix(name, "workflows/")
|
||||
return safeArchivePath(name) && strings.HasPrefix(name, "workflows/") && rest != "" && !strings.Contains(rest, "/") && strings.HasSuffix(rest, ".json")
|
||||
}
|
||||
|
||||
func safePackageWorkflowID(id string) bool {
|
||||
if id == "" || strings.ContainsAny(id, `/\`) {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseManifest(raw []byte) (Manifest, error) {
|
||||
var manifest Manifest
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&manifest); err != nil {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式无效")
|
||||
}
|
||||
if err := consumeJSONEnd(dec); err != nil || manifest.PackageFormat != PackageFormat || manifest.FormatVersion != FormatVersion || strings.TrimSpace(manifest.PackageID) == "" || len(manifest.Items) == 0 {
|
||||
return Manifest{}, packageError("WFPKG_INVALID_MANIFEST", "工作流包清单格式不受支持")
|
||||
}
|
||||
return manifest, nil
|
||||
}
|
||||
|
||||
func parseDocument(raw []byte) (Document, error) {
|
||||
var doc Document
|
||||
dec := json.NewDecoder(bytes.NewReader(raw))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&doc); err != nil || consumeJSONEnd(dec) != nil {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义格式无效")
|
||||
}
|
||||
doc.ID = strings.TrimSpace(doc.ID)
|
||||
doc.Name = strings.TrimSpace(doc.Name)
|
||||
if doc.ID == "" || doc.Name == "" || doc.Version <= 0 || strings.TrimSpace(doc.GraphJSON) == "" {
|
||||
return Document{}, packageError("WFPKG_WORKFLOW_INVALID", "工作流定义缺少必需字段")
|
||||
}
|
||||
return doc, nil
|
||||
}
|
||||
|
||||
func consumeJSONEnd(dec *json.Decoder) error {
|
||||
var extra any
|
||||
if err := dec.Decode(&extra); err != io.EOF {
|
||||
if err == nil {
|
||||
return fmt.Errorf("multiple JSON values")
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseChecksums(raw []byte) (map[string]string, error) {
|
||||
entries := make(map[string]string)
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") {
|
||||
parts := strings.SplitN(strings.TrimSpace(line), " ", 2)
|
||||
if len(parts) != 2 || !validHash("sha256:"+parts[0]) || !safeArchivePath(parts[1]) {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和格式无效")
|
||||
}
|
||||
if _, exists := entries[parts[1]]; exists {
|
||||
return nil, packageError("WFPKG_CHECKSUM_MISMATCH", "工作流包校验和重复")
|
||||
}
|
||||
entries[parts[1]] = "sha256:" + parts[0]
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInspectArchiveAcceptsSingleVerifiedWorkflow(t *testing.T) {
|
||||
pkg, meta, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return nil })
|
||||
if err != nil {
|
||||
t.Fatalf("InspectArchive: %v", err)
|
||||
}
|
||||
if result.PackageHash != meta.PackageHash || result.Document.ID != "web-src-hunting" {
|
||||
t.Fatalf("unexpected inspection: %#v", result)
|
||||
}
|
||||
if result.NodeCount != 2 || result.EdgeCount != 1 {
|
||||
t.Fatalf("counts = %d/%d, want 2/1", result.NodeCount, result.EdgeCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsUnsafeArchiveShapes(t *testing.T) {
|
||||
valid, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
archive []byte
|
||||
}{
|
||||
{name: "duplicate entry", archive: appendZipEntry(t, valid, "manifest.json", []byte(`{}`), 0)},
|
||||
{name: "path traversal", archive: appendZipEntry(t, valid, "../payload.json", []byte(`{}`), 0)},
|
||||
{name: "symlink", archive: appendZipEntry(t, valid, "workflows/link.json", []byte("target"), 0o120777)},
|
||||
{name: "undeclared file", archive: appendZipEntry(t, valid, "notes.txt", []byte("not allowed"), 0)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := InspectArchive(context.Background(), tc.archive, func(context.Context, string) error { return nil })
|
||||
if ErrorCode(err) != "WFPKG_INVALID_ARCHIVE" {
|
||||
t.Fatalf("code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInspectArchiveRejectsChecksumMismatchAndInvalidWorkflow(t *testing.T) {
|
||||
pkg, _, err := Export(testDocument())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
badChecksum := replaceZipEntry(t, pkg, "checksums.sha256", []byte("00 manifest.json\n"), 0)
|
||||
if _, err := InspectArchive(context.Background(), badChecksum, func(context.Context, string) error { return nil }); ErrorCode(err) != "WFPKG_CHECKSUM_MISMATCH" {
|
||||
t.Fatalf("checksum code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
if _, err := InspectArchive(context.Background(), pkg, func(context.Context, string) error { return errors.New("invalid graph") }); ErrorCode(err) != "WFPKG_WORKFLOW_INVALID" {
|
||||
t.Fatalf("graph code = %q, err = %v", ErrorCode(err), err)
|
||||
}
|
||||
}
|
||||
|
||||
func appendZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
return rewriteZip(t, archive, func(zw *zip.Writer) error {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = w.Write(data)
|
||||
return err
|
||||
})
|
||||
}
|
||||
|
||||
func replaceZipEntry(t *testing.T, archive []byte, name string, data []byte, mode os.FileMode) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
if f.Name == name {
|
||||
h := &zip.FileHeader{Name: name, Method: zip.Store}
|
||||
h.SetMode(mode)
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := w.Write(data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
|
||||
func rewriteZip(t *testing.T, archive []byte, appendEntry func(*zip.Writer) error) []byte {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
for _, f := range zr.File {
|
||||
r, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := &zip.FileHeader{Name: f.Name, Method: zip.Store}
|
||||
w, err := zw.CreateHeader(h)
|
||||
if err == nil {
|
||||
_, err = io.Copy(w, r)
|
||||
}
|
||||
_ = r.Close()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := appendEntry(zw); err != nil {
|
||||
t.Fatal(fmt.Errorf("append entry: %w", err))
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out.Bytes()
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package workflowpackage
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PackageFormat = "cyberstrikeai.workflow-package"
|
||||
FormatVersion = "1.0"
|
||||
)
|
||||
|
||||
// Document is the single non-executable workflow definition carried by a package.
|
||||
// Version is the source instance revision and is never applied as a target version.
|
||||
type Document struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdatedAt time.Time `json:"-"`
|
||||
}
|
||||
|
||||
type Manifest struct {
|
||||
PackageFormat string `json:"package_format"`
|
||||
FormatVersion string `json:"format_version"`
|
||||
PackageID string `json:"package_id"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Items []ManifestItem `json:"items"`
|
||||
}
|
||||
|
||||
type ManifestItem struct {
|
||||
Type string `json:"type"`
|
||||
Path string `json:"path"`
|
||||
SourceID string `json:"source_id"`
|
||||
SourceRevision int `json:"source_revision"`
|
||||
ContentHash string `json:"content_hash"`
|
||||
GraphHash string `json:"graph_hash"`
|
||||
}
|
||||
|
||||
type ExportMetadata struct {
|
||||
PackageHash string
|
||||
ContentHash string
|
||||
GraphHash string
|
||||
SourceRevision int
|
||||
FileName string
|
||||
}
|
||||
|
||||
func canonicalJSON(raw []byte) ([]byte, error) {
|
||||
dec := json.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.UseNumber()
|
||||
var value any
|
||||
if err := dec.Decode(&value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if dec.More() {
|
||||
return nil, fmt.Errorf("extra JSON values")
|
||||
}
|
||||
return json.Marshal(value)
|
||||
}
|
||||
|
||||
func sha256Prefixed(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func validHash(value string) bool {
|
||||
if !strings.HasPrefix(value, "sha256:") || len(value) != len("sha256:")+64 {
|
||||
return false
|
||||
}
|
||||
_, err := hex.DecodeString(strings.TrimPrefix(value, "sha256:"))
|
||||
return err == nil && value == strings.ToLower(value)
|
||||
}
|
||||
@@ -1,223 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/config"
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ShouldAutoRunRoleWorkflow returns true when a role explicitly binds a workflow
|
||||
// and does not turn it off. Empty policy defaults to auto to keep role UX simple.
|
||||
func ShouldAutoRunRoleWorkflow(role config.RoleConfig) bool {
|
||||
if strings.TrimSpace(role.WorkflowID) == "" {
|
||||
return false
|
||||
}
|
||||
policy := strings.ToLower(strings.TrimSpace(role.WorkflowPolicy))
|
||||
return policy == "" || policy == "auto"
|
||||
}
|
||||
|
||||
// RunRoleBoundWorkflow executes the persisted role-bound workflow via cached Eino Workflow.
|
||||
func RunRoleBoundWorkflow(ctx context.Context, args RunArgs) (*RunResult, error) {
|
||||
if args.DB == nil {
|
||||
return nil, fmt.Errorf("workflow db is nil")
|
||||
}
|
||||
workflowID := strings.TrimSpace(args.Role.WorkflowID)
|
||||
if workflowID == "" {
|
||||
return nil, fmt.Errorf("角色未绑定工作流")
|
||||
}
|
||||
wf, err := args.DB.GetWorkflowDefinition(workflowID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if wf == nil {
|
||||
return nil, fmt.Errorf("角色绑定的工作流不存在: %s", workflowID)
|
||||
}
|
||||
if !wf.Enabled {
|
||||
return nil, fmt.Errorf("角色绑定的工作流已禁用: %s", workflowID)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
runID := uuid.NewString()
|
||||
input := map[string]interface{}{
|
||||
"message": args.UserMessage,
|
||||
"conversationId": args.ConversationID,
|
||||
"projectId": args.ProjectID,
|
||||
"role": args.Role.Name,
|
||||
"workflowId": wf.ID,
|
||||
"workflowVersion": wf.Version,
|
||||
}
|
||||
inputJSON, _ := json.Marshal(input)
|
||||
run := &database.WorkflowRun{
|
||||
ID: runID,
|
||||
WorkflowID: wf.ID,
|
||||
WorkflowVersion: wf.Version,
|
||||
ConversationID: args.ConversationID,
|
||||
ProjectID: args.ProjectID,
|
||||
RoleID: args.Role.Name,
|
||||
Status: "running",
|
||||
InputJSON: string(inputJSON),
|
||||
StartedAt: time.Now(),
|
||||
}
|
||||
if err := args.DB.CreateWorkflowRun(run); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_start", fmt.Sprintf("开始运行流程「%s」", wf.Name), map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"conversationId": args.ConversationID,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
|
||||
graph, err := parseGraph(wf.GraphJSON)
|
||||
if err != nil {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
state := newWorkflowLocalState(input, runID)
|
||||
streaming := args.Progress != nil
|
||||
resuming := false
|
||||
for {
|
||||
_, err := invokeEinoGraph(ctx, args, runID, wf.ID, wf.Version, graph, state, resuming)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
if !IsAwaitingHITL(err) {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "failed", "", err.Error())
|
||||
return nil, err
|
||||
}
|
||||
hitl := err.(*AwaitingHITLError)
|
||||
partial := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "awaiting_hitl",
|
||||
"outputs": state.Outputs,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"pendingHitl": map[string]interface{}{
|
||||
"nodeId": hitl.NodeID,
|
||||
"label": hitl.NodeLabel,
|
||||
"prompt": hitl.Prompt,
|
||||
},
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
partialJSON, _ := json.Marshal(partial)
|
||||
_ = args.DB.SetWorkflowRunAwaitingHITL(runID, hitl.NodeID, string(partialJSON))
|
||||
response := fmt.Sprintf("工作流「%s」已在节点「%s」暂停,等待人工审批。\n运行 ID:%s", wf.Name, firstNonEmpty(hitl.NodeLabel, hitl.NodeID), runID)
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_paused", response, map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"status": "awaiting_hitl",
|
||||
"nodeId": hitl.NodeID,
|
||||
"resumeApi": fmt.Sprintf("/api/workflows/runs/%s/resume", runID),
|
||||
})
|
||||
}
|
||||
if !streaming {
|
||||
return &RunResult{
|
||||
Response: response,
|
||||
RunID: runID,
|
||||
Status: "awaiting_hitl",
|
||||
AwaitingHITL: true,
|
||||
}, nil
|
||||
}
|
||||
ch := registerHITLWaiter(runID)
|
||||
decision, waitErr := waitWorkflowHITLDecisionWithChannel(ctx, args.DB, runID, ch)
|
||||
unregisterHITLWaiter(runID, ch)
|
||||
if waitErr != nil {
|
||||
_ = args.DB.FinishWorkflowRun(runID, "cancelled", "", waitErr.Error())
|
||||
return nil, waitErr
|
||||
}
|
||||
if !decision.Approved {
|
||||
errText := strings.TrimSpace(decision.Comment)
|
||||
if errText == "" {
|
||||
errText = "人工审批拒绝"
|
||||
}
|
||||
_ = args.DB.FinishWorkflowRun(runID, "rejected", "", errText)
|
||||
rejectResponse := fmt.Sprintf("工作流已在审批节点「%s」被拒绝。", firstNonEmpty(hitl.NodeLabel, hitl.NodeID))
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_rejected", rejectResponse, map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": hitl.NodeID,
|
||||
"comment": errText,
|
||||
})
|
||||
}
|
||||
return &RunResult{
|
||||
Response: rejectResponse,
|
||||
RunID: runID,
|
||||
Status: "rejected",
|
||||
}, nil
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_hitl_resumed", "人工审批已通过,继续执行", map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"nodeId": hitl.NodeID,
|
||||
"comment": decision.Comment,
|
||||
})
|
||||
}
|
||||
if state.Inputs == nil {
|
||||
state.Inputs = map[string]any{}
|
||||
}
|
||||
state.Inputs["_hitl_approved"] = true
|
||||
state.Inputs["_hitl_comment"] = decision.Comment
|
||||
state.Inputs["_hitl_node_id"] = hitl.NodeID
|
||||
_ = args.DB.SetWorkflowRunStatus(runID, "running")
|
||||
resuming = true
|
||||
}
|
||||
|
||||
output := map[string]interface{}{
|
||||
"workflowId": wf.ID,
|
||||
"workflowName": wf.Name,
|
||||
"workflowVersion": wf.Version,
|
||||
"workflowRunId": runID,
|
||||
"status": "completed",
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"executedNodes": state.Executed,
|
||||
"skippedNodes": state.Skipped,
|
||||
"engine": "eino_workflow",
|
||||
}
|
||||
outputJSON, _ := json.Marshal(output)
|
||||
|
||||
response := renderWorkflowResponse(args.Role.Name, wf.Name, wf.Version, runID, state)
|
||||
if err := args.DB.FinishWorkflowRun(runID, "completed", string(outputJSON), ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if args.Progress != nil {
|
||||
args.Progress("workflow_done", fmt.Sprintf("流程「%s」运行完成", wf.Name), map[string]interface{}{
|
||||
"workflowRunId": runID,
|
||||
"workflowId": wf.ID,
|
||||
"outputs": state.Outputs,
|
||||
"metrics": state.Metrics,
|
||||
"response": response,
|
||||
"engine": "eino_workflow",
|
||||
})
|
||||
}
|
||||
if args.Logger != nil {
|
||||
args.Logger.Info("role-bound workflow completed",
|
||||
zap.String("workflow_id", wf.ID),
|
||||
zap.String("workflow_run_id", runID),
|
||||
zap.String("conversation_id", args.ConversationID),
|
||||
zap.String("role", args.Role.Name),
|
||||
zap.String("engine", "eino_workflow"),
|
||||
)
|
||||
}
|
||||
return &RunResult{Response: response, RunID: runID, Status: "completed"}, nil
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
)
|
||||
|
||||
func init() {
|
||||
schema.RegisterName[*WorkflowLocalState]("_cyberstrike_workflow_local_state")
|
||||
schema.RegisterName[NodeOutputEnvelope]("_cyberstrike_workflow_node_output_envelope")
|
||||
schema.RegisterName[StartOutput]("_cyberstrike_workflow_start_output")
|
||||
schema.RegisterName[ConditionOutput]("_cyberstrike_workflow_condition_output")
|
||||
schema.RegisterName[ToolOutput]("_cyberstrike_workflow_tool_output")
|
||||
schema.RegisterName[AgentOutput]("_cyberstrike_workflow_agent_output")
|
||||
schema.RegisterName[HITLOutput]("_cyberstrike_workflow_hitl_output")
|
||||
schema.RegisterName[OutputNodeOutput]("_cyberstrike_workflow_output_node_output")
|
||||
}
|
||||
|
||||
// WorkflowLocalState is the Eino WithGenLocalState payload (checkpoint-serializable).
|
||||
type WorkflowLocalState struct {
|
||||
Inputs map[string]any `json:"inputs,omitempty"`
|
||||
Outputs map[string]any `json:"outputs,omitempty"`
|
||||
NodeOutputs map[string]map[string]any `json:"nodeOutputs,omitempty"`
|
||||
NodeProceed map[string]bool `json:"nodeProceed,omitempty"`
|
||||
LastOutput map[string]any `json:"lastOutput,omitempty"`
|
||||
Metrics map[string]any `json:"metrics,omitempty"`
|
||||
Executed []string `json:"executed,omitempty"`
|
||||
Skipped []string `json:"skipped,omitempty"`
|
||||
WorkflowRunID string `json:"workflowRunId,omitempty"`
|
||||
MainIterationOffset int `json:"mainIterationOffset,omitempty"`
|
||||
SegmentMaxIteration int `json:"segmentMaxIteration,omitempty"`
|
||||
}
|
||||
|
||||
func newWorkflowLocalState(inputs map[string]interface{}, runID string) *WorkflowLocalState {
|
||||
in := make(map[string]any, len(inputs))
|
||||
for k, v := range inputs {
|
||||
in[k] = v
|
||||
}
|
||||
return &WorkflowLocalState{
|
||||
Inputs: in,
|
||||
Outputs: make(map[string]any),
|
||||
NodeOutputs: make(map[string]map[string]any),
|
||||
NodeProceed: make(map[string]bool),
|
||||
Metrics: make(map[string]any),
|
||||
WorkflowRunID: runID,
|
||||
}
|
||||
}
|
||||
|
||||
var templateVarRe = regexp.MustCompile(`\{\{\s*([a-zA-Z0-9_.-]+)\s*\}\}`)
|
||||
|
||||
func resolveTemplate(s string, state *WorkflowLocalState) string {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return fmt.Sprint(valueFromPath("previous.output", state))
|
||||
}
|
||||
return templateVarRe.ReplaceAllStringFunc(s, func(match string) string {
|
||||
m := templateVarRe.FindStringSubmatch(match)
|
||||
if len(m) != 2 {
|
||||
return match
|
||||
}
|
||||
return fmt.Sprint(valueFromPath(m[1], state))
|
||||
})
|
||||
}
|
||||
|
||||
func valueFromPath(path string, state *WorkflowLocalState) any {
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
var cur any
|
||||
switch parts[0] {
|
||||
case "inputs", "input":
|
||||
cur = state.Inputs
|
||||
case "previous", "prev":
|
||||
cur = state.LastOutput
|
||||
case "outputs":
|
||||
cur = state.Outputs
|
||||
default:
|
||||
if v, ok := state.Inputs[parts[0]]; ok {
|
||||
cur = v
|
||||
} else if v, ok := state.NodeOutputs[parts[0]]; ok {
|
||||
cur = v
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
for _, p := range parts[1:] {
|
||||
m, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
cur = m[p]
|
||||
}
|
||||
if cur == nil {
|
||||
return ""
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
func cleanComparable(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
s = strings.Trim(s, `"'`)
|
||||
return s
|
||||
}
|
||||
|
||||
func edgeAllowed(edge graphEdge, sourceNode graphNode, edgeIndex int, state *WorkflowLocalState) bool {
|
||||
cond := firstNonEmpty(cfgString(edge.Config, "condition"), cfgString(edge.Config, "expression"))
|
||||
if cond != "" {
|
||||
return evalCondition(cond, state)
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(sourceNode.Type), "condition") {
|
||||
return conditionBranchAllowed(edge, edgeIndex, state)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func conditionBranchAllowed(edge graphEdge, edgeIndex int, state *WorkflowLocalState) bool {
|
||||
matched := conditionMatched(state)
|
||||
if branch := conditionBranchHint(edge); branch != "" {
|
||||
return (branch == "true" && matched) || (branch == "false" && !matched)
|
||||
}
|
||||
switch edgeIndex {
|
||||
case 0:
|
||||
return matched
|
||||
case 1:
|
||||
return !matched
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func conditionMatched(state *WorkflowLocalState) bool {
|
||||
v := strings.ToLower(cleanComparable(fmt.Sprint(valueFromPath("previous.matched", state))))
|
||||
return v == "true" || v == "1"
|
||||
}
|
||||
|
||||
func conditionBranchHint(edge graphEdge) string {
|
||||
if edge.Config != nil {
|
||||
switch strings.ToLower(strings.TrimSpace(cfgString(edge.Config, "branch"))) {
|
||||
case "true", "yes", "y", "是":
|
||||
return "true"
|
||||
case "false", "no", "n", "否":
|
||||
return "false"
|
||||
}
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(edge.Label)) {
|
||||
case "true", "yes", "y", "是":
|
||||
return "true"
|
||||
case "false", "no", "n", "否":
|
||||
return "false"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cfgString(cfg map[string]any, key string) string {
|
||||
if cfg == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := cfg[key]; ok {
|
||||
return strings.TrimSpace(fmt.Sprint(v))
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if s := strings.TrimSpace(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func truncateWorkflowPreview(s string, limit int) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if limit <= 0 || len([]rune(s)) <= limit {
|
||||
return s
|
||||
}
|
||||
runes := []rune(s)
|
||||
return string(runes[:limit]) + "..."
|
||||
}
|
||||
|
||||
func renderWorkflowResponse(roleName, workflowName string, version int, runID string, state *WorkflowLocalState) string {
|
||||
var sb strings.Builder
|
||||
sb.WriteString(fmt.Sprintf("角色「%s」已完成工作流「%s」(版本 %d)。\n\n", roleName, workflowName, version))
|
||||
sb.WriteString(fmt.Sprintf("运行 ID:%s\n", runID))
|
||||
sb.WriteString(fmt.Sprintf("已执行节点:%d", len(state.Executed)))
|
||||
if len(state.Skipped) > 0 {
|
||||
sb.WriteString(fmt.Sprintf(",跳过节点:%d", len(state.Skipped)))
|
||||
}
|
||||
sb.WriteString("\n\n")
|
||||
if len(state.Outputs) > 0 {
|
||||
sb.WriteString("输出:\n")
|
||||
keys := make([]string, 0, len(state.Outputs))
|
||||
for k := range state.Outputs {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, k := range keys {
|
||||
sb.WriteString(fmt.Sprintf("- %s:%v\n", k, state.Outputs[k]))
|
||||
}
|
||||
} else {
|
||||
sb.WriteString("暂无输出。请检查是否配置了输出节点,或条件分支是否命中。\n")
|
||||
}
|
||||
if len(state.Skipped) > 0 {
|
||||
sb.WriteString("\n未执行的节点类型仍会保留运行记录:")
|
||||
sb.WriteString(strings.Join(state.Skipped, "、"))
|
||||
sb.WriteString("。")
|
||||
}
|
||||
return strings.TrimSpace(sb.String())
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
package workflow
|
||||
|
||||
type NodeOutputEnvelope struct {
|
||||
Kind string `json:"kind"`
|
||||
NodeID string `json:"node_id"`
|
||||
NodeType string `json:"node_type"`
|
||||
Status string `json:"status"`
|
||||
Output any `json:"output"`
|
||||
}
|
||||
|
||||
type StartOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Message any `json:"message"`
|
||||
ConversationID any `json:"conversationId"`
|
||||
ProjectID any `json:"projectId"`
|
||||
}
|
||||
|
||||
type ConditionOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Condition string `json:"condition"`
|
||||
Matched bool `json:"matched"`
|
||||
}
|
||||
|
||||
type ToolOutput struct {
|
||||
NodeOutputEnvelope
|
||||
ToolName string `json:"tool_name"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
ExecutionID string `json:"execution_id"`
|
||||
IsError bool `json:"is_error"`
|
||||
}
|
||||
|
||||
type AgentOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Mode string `json:"mode"`
|
||||
MCPExecutionIDs []string `json:"mcp_execution_ids"`
|
||||
}
|
||||
|
||||
type HITLOutput struct {
|
||||
NodeOutputEnvelope
|
||||
Prompt string `json:"prompt"`
|
||||
Reviewer string `json:"reviewer"`
|
||||
Approved bool `json:"approved"`
|
||||
Mode string `json:"mode"`
|
||||
}
|
||||
|
||||
type OutputNodeOutput struct {
|
||||
NodeOutputEnvelope
|
||||
OutputKey string `json:"output_key"`
|
||||
Outputs map[string]any `json:"outputs"`
|
||||
}
|
||||
|
||||
func envelope(kind, nodeID, nodeType, status string, output any) NodeOutputEnvelope {
|
||||
return NodeOutputEnvelope{Kind: kind, NodeID: nodeID, NodeType: nodeType, Status: status, Output: output}
|
||||
}
|
||||
|
||||
func outputMap(env NodeOutputEnvelope, extra map[string]any) map[string]any {
|
||||
out := map[string]any{
|
||||
"kind": env.Kind,
|
||||
"node_id": env.NodeID,
|
||||
"node_type": env.NodeType,
|
||||
"status": env.Status,
|
||||
"output": env.Output,
|
||||
"typed": env,
|
||||
}
|
||||
for k, v := range extra {
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func startOutputMap(node graphNode, message, conversationID, projectID any) map[string]any {
|
||||
typed := StartOutput{
|
||||
NodeOutputEnvelope: envelope("start", node.ID, node.Type, "completed", message),
|
||||
Message: message,
|
||||
ConversationID: conversationID,
|
||||
ProjectID: projectID,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"message": typed.Message,
|
||||
"conversationId": typed.ConversationID,
|
||||
"projectId": typed.ProjectID,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func conditionOutputMap(node graphNode, expr string, matched bool) map[string]any {
|
||||
typed := ConditionOutput{
|
||||
NodeOutputEnvelope: envelope("condition", node.ID, node.Type, "completed", matched),
|
||||
Condition: expr,
|
||||
Matched: matched,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"condition": expr, "matched": matched, "typed": typed})
|
||||
}
|
||||
|
||||
func outputNodeOutputMap(node graphNode, key string, value any) map[string]any {
|
||||
typed := OutputNodeOutput{
|
||||
NodeOutputEnvelope: envelope("output", node.ID, node.Type, "completed", value),
|
||||
OutputKey: key,
|
||||
Outputs: map[string]any{key: value},
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"output_key": key, "outputs": typed.Outputs, "typed": typed})
|
||||
}
|
||||
|
||||
func endOutputMap(node graphNode, value any) map[string]any {
|
||||
typed := envelope("end", node.ID, node.Type, "completed", value)
|
||||
return outputMap(typed, nil)
|
||||
}
|
||||
|
||||
func toolOutputMap(node graphNode, output string, toolName string, args map[string]any, executionID string, isError bool) map[string]any {
|
||||
typed := ToolOutput{
|
||||
NodeOutputEnvelope: envelope("tool", node.ID, node.Type, "completed", output),
|
||||
ToolName: toolName,
|
||||
Arguments: args,
|
||||
ExecutionID: executionID,
|
||||
IsError: isError,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"tool_name": toolName,
|
||||
"arguments": args,
|
||||
"execution_id": executionID,
|
||||
"is_error": isError,
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
|
||||
func agentOutputMap(node graphNode, response, mode string, mcpIDs []string) map[string]any {
|
||||
typed := AgentOutput{
|
||||
NodeOutputEnvelope: envelope("agent", node.ID, node.Type, "completed", response),
|
||||
Mode: mode,
|
||||
MCPExecutionIDs: mcpIDs,
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{"mode": mode, "mcp_execution_ids": mcpIDs, "typed": typed})
|
||||
}
|
||||
|
||||
func hitlOutputMap(node graphNode, status string, output string, prompt string, reviewer string, approved bool) map[string]any {
|
||||
typed := HITLOutput{
|
||||
NodeOutputEnvelope: envelope("hitl", node.ID, node.Type, status, output),
|
||||
Prompt: prompt,
|
||||
Reviewer: reviewer,
|
||||
Approved: approved,
|
||||
Mode: "interactive",
|
||||
}
|
||||
return outputMap(typed.NodeOutputEnvelope, map[string]any{
|
||||
"prompt": prompt,
|
||||
"reviewer": reviewer,
|
||||
"approved": approved,
|
||||
"mode": "interactive",
|
||||
"typed": typed,
|
||||
})
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTruncateWorkflowToolOutputBoundsBytesAndKeepsExecutionReference(t *testing.T) {
|
||||
out := truncateWorkflowToolOutput(strings.Repeat("响应正文", 1000), 256, "exec-123")
|
||||
if len(out) > 256 {
|
||||
t.Fatalf("workflow output bytes=%d, want <=256", len(out))
|
||||
}
|
||||
if !strings.Contains(out, "exec-123") || !strings.Contains(out, "truncated") {
|
||||
t.Fatalf("missing truncation reference: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTruncateWorkflowToolOutputLeavesBoundedContentUntouched(t *testing.T) {
|
||||
const want = "small-result"
|
||||
if got := truncateWorkflowToolOutput(want, 256, "exec-123"); got != want {
|
||||
t.Fatalf("got %q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// WorkflowInput is the typed entry for Eino compose.Workflow[I,O].
|
||||
type WorkflowInput struct {
|
||||
Message string `json:"message"`
|
||||
ConversationID string `json:"conversationId"`
|
||||
ProjectID string `json:"projectId"`
|
||||
Role string `json:"role"`
|
||||
WorkflowID string `json:"workflowId"`
|
||||
WorkflowVersion int `json:"workflowVersion"`
|
||||
}
|
||||
|
||||
// WorkflowOutput aggregates terminal node payloads keyed by canvas node id.
|
||||
type WorkflowOutput map[string]any
|
||||
|
||||
// WorkflowNodeOutput is the per-node lambda payload (alias for Eino edge type alignment).
|
||||
type WorkflowNodeOutput = map[string]interface{}
|
||||
|
||||
func workflowInputFromMap(m map[string]interface{}) WorkflowInput {
|
||||
in := WorkflowInput{}
|
||||
if m == nil {
|
||||
return in
|
||||
}
|
||||
if v, ok := m["message"].(string); ok {
|
||||
in.Message = v
|
||||
} else if m["message"] != nil {
|
||||
in.Message = fmt.Sprint(m["message"])
|
||||
}
|
||||
if v, ok := m["conversationId"].(string); ok {
|
||||
in.ConversationID = v
|
||||
}
|
||||
if v, ok := m["projectId"].(string); ok {
|
||||
in.ProjectID = v
|
||||
}
|
||||
if v, ok := m["role"].(string); ok {
|
||||
in.Role = v
|
||||
}
|
||||
if v, ok := m["workflowId"].(string); ok {
|
||||
in.WorkflowID = v
|
||||
}
|
||||
switch v := m["workflowVersion"].(type) {
|
||||
case int:
|
||||
in.WorkflowVersion = v
|
||||
case int64:
|
||||
in.WorkflowVersion = int(v)
|
||||
case float64:
|
||||
in.WorkflowVersion = int(v)
|
||||
case string:
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
in.WorkflowVersion = n
|
||||
}
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
func (in WorkflowInput) toStateInputs() map[string]any {
|
||||
return map[string]any{
|
||||
"message": in.Message,
|
||||
"conversationId": in.ConversationID,
|
||||
"projectId": in.ProjectID,
|
||||
"role": in.Role,
|
||||
"workflowId": in.WorkflowID,
|
||||
"workflowVersion": in.WorkflowVersion,
|
||||
}
|
||||
}
|
||||
|
||||
func cacheKey(workflowID string, version int) string {
|
||||
return workflowID + ":" + strconv.Itoa(version)
|
||||
}
|
||||
@@ -1,366 +0,0 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user