mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-07-07 13:07:57 +02:00
174 lines
5.5 KiB
Go
174 lines
5.5 KiB
Go
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
|
||
}
|