Files
CyberStrikeAI/internal/workflow/eino_compile_test.go
T
chenchen 612015b6d7 修复工作流检查点序列化 (#193)
Co-authored-by: ruanmingchen <“ruanm@chenchen”>
2026-07-11 01:38:10 +08:00

413 lines
14 KiB
Go

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")
}
}