From c7603c2b661e879b01505cc9356f3e99b1a4c51f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=85=AC=E6=98=8E?= <83812544+Ed1s0nZ@users.noreply.github.com> Date: Tue, 18 Aug 2026 20:17:38 +0800 Subject: [PATCH] Add files via upload --- internal/authctx/principal.go | 72 ++ internal/monitor/reconcile.go | 101 +++ internal/monitor/reconcile_test.go | 38 + internal/monitor/retention.go | 71 ++ internal/monitor/retention_test.go | 94 +++ internal/robot/conn.go | 6 + internal/robot/ding.go | 151 ++++ internal/robot/discord.go | 121 +++ internal/robot/ilink/client.go | 316 ++++++++ internal/robot/ilink/qrcode_image.go | 26 + internal/robot/lark.go | 141 ++++ internal/robot/proactive.go | 192 +++++ internal/robot/qq.go | 209 +++++ internal/robot/reconnect.go | 38 + internal/robot/slack.go | 135 ++++ internal/robot/split.go | 29 + internal/robot/telegram.go | 262 +++++++ internal/robot/wechat.go | 96 +++ internal/workflow/agent_subgraph.go | 48 ++ internal/workflow/bindings.go | 153 ++++ internal/workflow/checkpoint_store.go | 69 ++ internal/workflow/draft_generator.go | 782 +++++++++++++++++++ internal/workflow/draft_generator_test.go | 213 +++++ internal/workflow/dry_run.go | 173 ++++ internal/workflow/eino_branch.go | 107 +++ internal/workflow/eino_callbacks.go | 22 + internal/workflow/eino_compile.go | 243 ++++++ internal/workflow/eino_compile_test.go | 412 ++++++++++ internal/workflow/eino_runtime.go | 64 ++ internal/workflow/engine.go | 239 ++++++ internal/workflow/errors.go | 24 + internal/workflow/expression.go | 186 +++++ internal/workflow/expression_join_test.go | 107 +++ internal/workflow/graph_types.go | 153 ++++ internal/workflow/hitl_wait.go | 119 +++ internal/workflow/join.go | 117 +++ internal/workflow/jsonpath.go | 115 +++ internal/workflow/metrics.go | 57 ++ internal/workflow/node_exec.go | 153 ++++ internal/workflow/nodes.go | 333 ++++++++ internal/workflow/package/exporter.go | 98 +++ internal/workflow/package/exporter_test.go | 58 ++ internal/workflow/package/inspector.go | 225 ++++++ internal/workflow/package/inspector_test.go | 154 ++++ internal/workflow/package/manifest.go | 78 ++ internal/workflow/runner.go | 223 ++++++ internal/workflow/state.go | 214 +++++ internal/workflow/structured_outputs.go | 150 ++++ internal/workflow/tool_output_budget_test.go | 23 + internal/workflow/types.go | 74 ++ internal/workflow/validation.go | 366 +++++++++ 51 files changed, 7650 insertions(+) create mode 100644 internal/authctx/principal.go create mode 100644 internal/monitor/reconcile.go create mode 100644 internal/monitor/reconcile_test.go create mode 100644 internal/monitor/retention.go create mode 100644 internal/monitor/retention_test.go create mode 100644 internal/robot/conn.go create mode 100644 internal/robot/ding.go create mode 100644 internal/robot/discord.go create mode 100644 internal/robot/ilink/client.go create mode 100644 internal/robot/ilink/qrcode_image.go create mode 100644 internal/robot/lark.go create mode 100644 internal/robot/proactive.go create mode 100644 internal/robot/qq.go create mode 100644 internal/robot/reconnect.go create mode 100644 internal/robot/slack.go create mode 100644 internal/robot/split.go create mode 100644 internal/robot/telegram.go create mode 100644 internal/robot/wechat.go create mode 100644 internal/workflow/agent_subgraph.go create mode 100644 internal/workflow/bindings.go create mode 100644 internal/workflow/checkpoint_store.go create mode 100644 internal/workflow/draft_generator.go create mode 100644 internal/workflow/draft_generator_test.go create mode 100644 internal/workflow/dry_run.go create mode 100644 internal/workflow/eino_branch.go create mode 100644 internal/workflow/eino_callbacks.go create mode 100644 internal/workflow/eino_compile.go create mode 100644 internal/workflow/eino_compile_test.go create mode 100644 internal/workflow/eino_runtime.go create mode 100644 internal/workflow/engine.go create mode 100644 internal/workflow/errors.go create mode 100644 internal/workflow/expression.go create mode 100644 internal/workflow/expression_join_test.go create mode 100644 internal/workflow/graph_types.go create mode 100644 internal/workflow/hitl_wait.go create mode 100644 internal/workflow/join.go create mode 100644 internal/workflow/jsonpath.go create mode 100644 internal/workflow/metrics.go create mode 100644 internal/workflow/node_exec.go create mode 100644 internal/workflow/nodes.go create mode 100644 internal/workflow/package/exporter.go create mode 100644 internal/workflow/package/exporter_test.go create mode 100644 internal/workflow/package/inspector.go create mode 100644 internal/workflow/package/inspector_test.go create mode 100644 internal/workflow/package/manifest.go create mode 100644 internal/workflow/runner.go create mode 100644 internal/workflow/state.go create mode 100644 internal/workflow/structured_outputs.go create mode 100644 internal/workflow/tool_output_budget_test.go create mode 100644 internal/workflow/types.go create mode 100644 internal/workflow/validation.go diff --git a/internal/authctx/principal.go b/internal/authctx/principal.go new file mode 100644 index 00000000..f74199ac --- /dev/null +++ b/internal/authctx/principal.go @@ -0,0 +1,72 @@ +package authctx + +import ( + "context" + "strings" +) + +// Principal is the immutable authorization identity propagated beyond the +// transport layer into Agent, MCP and background task contexts. +type Principal struct { + UserID string + Username string + Permissions map[string]bool + PermissionScopes map[string]string + Scope string +} + +type principalContextKey struct{} + +func NewPrincipal(userID, username, scope string, permissions map[string]bool) Principal { + return NewPrincipalWithScopes(userID, username, scope, permissions, nil) +} + +func NewPrincipalWithScopes(userID, username, scope string, permissions map[string]bool, permissionScopes map[string]string) Principal { + permissionCopy := make(map[string]bool, len(permissions)) + scopeCopy := make(map[string]string, len(permissionScopes)) + for permission, allowed := range permissions { + if allowed { + permissionCopy[permission] = true + if permissionScope := strings.TrimSpace(permissionScopes[permission]); permissionScope != "" { + scopeCopy[permission] = permissionScope + } + } + } + return Principal{ + UserID: strings.TrimSpace(userID), Username: strings.TrimSpace(username), + Scope: strings.TrimSpace(scope), Permissions: permissionCopy, PermissionScopes: scopeCopy, + } +} + +func WithPrincipal(ctx context.Context, principal Principal) context.Context { + if ctx == nil { + ctx = context.Background() + } + if strings.TrimSpace(principal.UserID) == "" { + return ctx + } + return context.WithValue(ctx, principalContextKey{}, principal) +} + +func PrincipalFromContext(ctx context.Context) (Principal, bool) { + if ctx == nil { + return Principal{}, false + } + principal, ok := ctx.Value(principalContextKey{}).(Principal) + return principal, ok && strings.TrimSpace(principal.UserID) != "" +} + +func (p Principal) HasPermission(permission string) bool { + return p.Permissions[strings.TrimSpace(permission)] +} + +// ScopeFor returns the scope attached to the permission that authorizes the +// current action. Falling back to Scope keeps explicit service principals and +// legacy callers compatible without reintroducing cross-role scope widening. +func (p Principal) ScopeFor(permission string) string { + permission = strings.TrimSpace(permission) + if scope := strings.TrimSpace(p.PermissionScopes[permission]); scope != "" { + return scope + } + return strings.TrimSpace(p.Scope) +} diff --git a/internal/monitor/reconcile.go b/internal/monitor/reconcile.go new file mode 100644 index 00000000..526c0817 --- /dev/null +++ b/internal/monitor/reconcile.go @@ -0,0 +1,101 @@ +package monitor + +import ( + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + + "go.uber.org/zap" +) + +const ( + staleRunningMinAge = 45 * time.Second + staleRunningReconcileGap = 2 * time.Minute +) + +// ExecutionReconciler 在启动或运行期将无对应协程的 running 执行记录收尾为 orphaned。 +type ExecutionReconciler struct { + db *database.DB + mcpServer *mcp.Server + externalMgr *mcp.ExternalMCPManager + logger *zap.Logger +} + +// NewExecutionReconciler creates a reconciler for orphaned MCP tool executions. +func NewExecutionReconciler(db *database.DB, mcpServer *mcp.Server, externalMgr *mcp.ExternalMCPManager, logger *zap.Logger) *ExecutionReconciler { + return &ExecutionReconciler{ + db: db, + mcpServer: mcpServer, + externalMgr: externalMgr, + logger: logger, + } +} + +// ReconcileOnStartup marks every persisted running row as orphaned (safe right after process start). +func (r *ExecutionReconciler) ReconcileOnStartup() { + if r == nil || r.db == nil { + return + } + now := time.Now() + n, err := r.db.CancelOrphanedRunningToolExecutions(now, "执行已中断(服务重启)") + if err != nil { + if r.logger != nil { + r.logger.Warn("启动时清理孤儿 running 工具执行记录失败", zap.Error(err)) + } + return + } + if n > 0 && r.logger != nil { + r.logger.Info("启动时已收尾孤儿 running 工具执行记录", zap.Int64("count", n)) + } +} + +func (r *ExecutionReconciler) activeExecutionIDs() map[string]struct{} { + ids := make(map[string]struct{}) + if r.mcpServer != nil { + for id := range r.mcpServer.ActiveRunningExecutionIDs() { + ids[id] = struct{}{} + } + } + if r.externalMgr != nil { + for id := range r.externalMgr.ActiveRunningExecutionIDs() { + ids[id] = struct{}{} + } + } + return ids +} + +// ReconcileStaleRunning finalizes running rows that are not tracked in-memory and older than staleRunningMinAge. +func (r *ExecutionReconciler) ReconcileStaleRunning() { + if r == nil || r.db == nil { + return + } + now := time.Now() + n, err := r.db.FinalizeStaleRunningToolExecutions(now, staleRunningMinAge, r.activeExecutionIDs(), "执行已中断(会话已结束)") + if err != nil { + if r.logger != nil { + r.logger.Warn("定期收尾 stale running 工具执行记录失败", zap.Error(err)) + } + return + } + if n > 0 && r.logger != nil { + r.logger.Info("已收尾 stale running 工具执行记录", zap.Int64("count", n)) + } +} + +// StartStaleRunningReconcileLoop periodically reconciles orphaned running tool executions. +func StartStaleRunningReconcileLoop(r *ExecutionReconciler, logger *zap.Logger) { + if r == nil { + return + } + go func() { + ticker := time.NewTicker(staleRunningReconcileGap) + defer ticker.Stop() + for range ticker.C { + r.ReconcileStaleRunning() + if logger != nil { + logger.Debug("monitor stale running reconcile tick completed") + } + } + }() +} diff --git a/internal/monitor/reconcile_test.go b/internal/monitor/reconcile_test.go new file mode 100644 index 00000000..0dfad0e9 --- /dev/null +++ b/internal/monitor/reconcile_test.go @@ -0,0 +1,38 @@ +package monitor + +import ( + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + + "go.uber.org/zap" +) + +func TestExecutionReconciler_ReconcileOnStartup(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "monitor.db") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + + if err := db.SaveToolExecution(&mcp.ToolExecution{ + ID: "run-1", ToolName: "hydra", Status: "running", StartTime: time.Now().Add(-time.Hour), + }); err != nil { + t.Fatalf("SaveToolExecution: %v", err) + } + + r := NewExecutionReconciler(db, mcp.NewServer(zap.NewNop()), nil, zap.NewNop()) + r.ReconcileOnStartup() + + got, err := db.GetToolExecution("run-1") + if err != nil { + t.Fatalf("GetToolExecution: %v", err) + } + if got.Status != "orphaned" { + t.Fatalf("expected orphaned after startup reconcile, got %s", got.Status) + } +} diff --git a/internal/monitor/retention.go b/internal/monitor/retention.go new file mode 100644 index 00000000..d1ffb295 --- /dev/null +++ b/internal/monitor/retention.go @@ -0,0 +1,71 @@ +package monitor + +import ( + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + + "go.uber.org/zap" +) + +const retentionPurgeInterval = time.Hour + +// Service manages MCP tool execution monitor retention. +type Service struct { + db *database.DB + cfg *config.Config + logger *zap.Logger +} + +// NewService creates a monitor retention service. +func NewService(db *database.DB, cfg *config.Config, logger *zap.Logger) *Service { + return &Service{db: db, cfg: cfg, logger: logger} +} + +// RetentionDays returns configured retention; 0 means keep forever. +func (s *Service) RetentionDays() int { + if s == nil || s.cfg == nil { + return config.MonitorConfig{}.RetentionDaysEffective() + } + return s.cfg.Monitor.RetentionDaysEffective() +} + +// PurgeExpired deletes tool execution rows older than retention_days when configured. +func (s *Service) PurgeExpired() { + if s == nil || s.db == nil || s.cfg == nil { + return + } + days := s.cfg.Monitor.RetentionDaysEffective() + if days <= 0 { + return + } + cutoff := time.Now().AddDate(0, 0, -days) + n, err := s.db.PurgeToolExecutionsBefore(cutoff) + if err != nil { + if s.logger != nil { + s.logger.Warn("清理过期 MCP 执行记录失败", zap.Error(err)) + } + return + } + if n > 0 && s.logger != nil { + s.logger.Info("已清理过期 MCP 执行记录", zap.Int64("deleted", n), zap.Int("retention_days", days)) + } +} + +// StartRetentionLoop periodically purges expired tool execution rows. +func StartRetentionLoop(s *Service, logger *zap.Logger) { + if s == nil { + return + } + go func() { + ticker := time.NewTicker(retentionPurgeInterval) + defer ticker.Stop() + for range ticker.C { + s.PurgeExpired() + if logger != nil { + logger.Debug("monitor retention tick completed") + } + } + }() +} diff --git a/internal/monitor/retention_test.go b/internal/monitor/retention_test.go new file mode 100644 index 00000000..40425fd6 --- /dev/null +++ b/internal/monitor/retention_test.go @@ -0,0 +1,94 @@ +package monitor + +import ( + "path/filepath" + "testing" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/database" + "cyberstrike-ai/internal/mcp" + + "go.uber.org/zap" +) + +func TestServicePurgeExpired_respectsZeroRetention(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "monitor.db") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + + exec := &mcp.ToolExecution{ + ID: "ancient", + ToolName: "curl::get", + Arguments: map[string]interface{}{}, + Status: "completed", + StartTime: mustParseTime(t, "2020-01-01T00:00:00Z"), + } + if err := db.SaveToolExecution(exec); err != nil { + t.Fatalf("SaveToolExecution: %v", err) + } + + zero := 0 + svc := NewService(db, &config.Config{ + Monitor: config.MonitorConfig{RetentionDays: &zero}, + }, zap.NewNop()) + svc.PurgeExpired() + + if _, err := db.GetToolExecution("ancient"); err != nil { + t.Fatalf("record should remain when retention_days=0: %v", err) + } +} + +func TestServicePurgeExpired_deletesOldRows(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "monitor.db") + db, err := database.NewDB(dbPath, zap.NewNop()) + if err != nil { + t.Fatalf("NewDB: %v", err) + } + defer db.Close() + + exec := &mcp.ToolExecution{ + ID: "ancient", + ToolName: "curl::get", + Arguments: map[string]interface{}{}, + Status: "completed", + StartTime: mustParseTime(t, "2020-01-01T00:00:00Z"), + } + if err := db.SaveToolExecution(exec); err != nil { + t.Fatalf("SaveToolExecution: %v", err) + } + + days := 90 + svc := NewService(db, &config.Config{ + Monitor: config.MonitorConfig{RetentionDays: &days}, + }, zap.NewNop()) + svc.PurgeExpired() + + if _, err := db.GetToolExecution("ancient"); err == nil { + t.Fatal("record should be purged when older than retention_days") + } +} + +func TestRetentionDaysEffective_defaults(t *testing.T) { + got := config.MonitorConfig{}.RetentionDaysEffective() + if got != 90 { + t.Fatalf("default = %d, want 90", got) + } + zero := 0 + cfg := config.MonitorConfig{RetentionDays: &zero} + if cfg.RetentionDaysEffective() != 0 { + t.Fatalf("zero = %d, want 0", cfg.RetentionDaysEffective()) + } +} + +func mustParseTime(t *testing.T, value string) time.Time { + t.Helper() + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + t.Fatalf("parse time: %v", err) + } + return parsed +} diff --git a/internal/robot/conn.go b/internal/robot/conn.go new file mode 100644 index 00000000..d57e361d --- /dev/null +++ b/internal/robot/conn.go @@ -0,0 +1,6 @@ +package robot + +// MessageHandler 供飞书/钉钉长连接调用的消息处理接口(由 handler.RobotHandler 实现) +type MessageHandler interface { + HandleMessage(platform, userID, text string) string +} diff --git a/internal/robot/ding.go b/internal/robot/ding.go new file mode 100644 index 00000000..7f469808 --- /dev/null +++ b/internal/robot/ding.go @@ -0,0 +1,151 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "github.com/open-dingtalk/dingtalk-stream-sdk-go/chatbot" + "github.com/open-dingtalk/dingtalk-stream-sdk-go/client" + dingutils "github.com/open-dingtalk/dingtalk-stream-sdk-go/utils" + "go.uber.org/zap" +) + +const ( + dingReconnectInitial = 5 * time.Second // 首次重连间隔 + dingReconnectMax = 60 * time.Second // 最大重连间隔 +) + +// StartDing 启动钉钉 Stream 长连接(无需公网),收到消息后调用 handler 并通过 SessionWebhook 回复。 +// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 +func StartDing(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Dingtalk + if !cfg.Enabled || cfg.ClientID == "" || cfg.ClientSecret == "" { + return + } + go runDingLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger) +} + +// runDingLoop 循环维持钉钉长连接:断开且 ctx 未取消时按退避间隔重连。 +func runDingLoop(ctx context.Context, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { + backoff := dingReconnectInitial + for { + streamClient := client.NewStreamClient( + client.WithAppCredential(client.NewAppCredentialConfig(cfg.ClientID, cfg.ClientSecret)), + client.WithSubscription(dingutils.SubscriptionTypeKCallback, "/v1.0/im/bot/messages/get", + chatbot.NewDefaultChatBotFrameHandler(func(ctx context.Context, msg *chatbot.BotCallbackDataModel) ([]byte, error) { + go handleDingMessage(ctx, msg, cfg, strictUserIdentity, h, logger) + return nil, nil + }).OnEventReceived), + ) + logger.Info("钉钉 Stream 正在连接…", zap.String("client_id", cfg.ClientID)) + err := streamClient.Start(ctx) + if ctx.Err() != nil { + logger.Info("钉钉 Stream 已按配置重启关闭") + return + } + if err != nil { + logger.Warn("钉钉 Stream 长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + // 下次重连间隔递增,上限 60 秒,避免频繁重试 + if backoff < dingReconnectMax { + backoff *= 2 + if backoff > dingReconnectMax { + backoff = dingReconnectMax + } + } + } + } +} + +func handleDingMessage(ctx context.Context, msg *chatbot.BotCallbackDataModel, cfg config.RobotDingtalkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { + if msg == nil || msg.SessionWebhook == "" { + return + } + content := "" + if msg.Text.Content != "" { + content = strings.TrimSpace(msg.Text.Content) + } + if content == "" && msg.Msgtype == "richText" { + if cMap, ok := msg.Content.(map[string]interface{}); ok { + if rich, ok := cMap["richText"].([]interface{}); ok { + for _, c := range rich { + if m, ok := c.(map[string]interface{}); ok { + if txt, ok := m["text"].(string); ok { + content = strings.TrimSpace(txt) + break + } + } + } + } + } + } + if content == "" { + logger.Debug("钉钉消息内容为空,已忽略", zap.String("msgtype", msg.Msgtype)) + return + } + logger.Info("钉钉收到消息", zap.String("sender", msg.SenderId), zap.String("content", content)) + tenantKey := strings.TrimSpace(cfg.ClientID) + if tenantKey == "" { + tenantKey = "default" + } + userID := strings.TrimSpace(msg.SenderId) + if userID != "" { + userID = "t:" + tenantKey + "|u:" + userID + } else if cfg.AllowConversationIDFallback && !strictUserIdentity { + conversationID := strings.TrimSpace(msg.ConversationId) + if conversationID != "" { + userID = "t:" + tenantKey + "|c:" + conversationID + } + } + if userID == "" { + logger.Warn("钉钉消息缺少可用用户标识,已忽略") + return + } + reply := h.HandleMessage("dingtalk", userID, content) + // 使用 markdown 类型以便正确展示标题、列表、代码块等格式 + title := reply + if idx := strings.IndexAny(reply, "\n"); idx > 0 { + title = strings.TrimSpace(reply[:idx]) + } + if len(title) > 50 { + title = title[:50] + "…" + } + if title == "" { + title = "回复" + } + body := map[string]interface{}{ + "msgtype": "markdown", + "markdown": map[string]string{ + "title": title, + "text": reply, + }, + } + bodyBytes, _ := json.Marshal(body) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, msg.SessionWebhook, bytes.NewReader(bodyBytes)) + if err != nil { + logger.Warn("钉钉构造回复请求失败", zap.Error(err)) + return + } + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + logger.Warn("钉钉回复请求失败", zap.Error(err)) + return + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + logger.Warn("钉钉回复非 200", zap.Int("status", resp.StatusCode)) + return + } + logger.Debug("钉钉回复成功", zap.String("content_preview", reply)) +} diff --git a/internal/robot/discord.go b/internal/robot/discord.go new file mode 100644 index 00000000..4986e524 --- /dev/null +++ b/internal/robot/discord.go @@ -0,0 +1,121 @@ +package robot + +import ( + "context" + "strings" + + "cyberstrike-ai/internal/config" + + "github.com/bwmarrin/discordgo" + "go.uber.org/zap" +) + +const ( + discordPlatform = "discord" + discordMaxMessageRunes = 2000 +) + +// StartDiscord 启动 Discord Gateway(WebSocket,无需公网回调)。 +func StartDiscord(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Discord + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" { + return + } + go runDiscordLoop(ctx, cfg, h, logger) +} + +func runDiscordLoop(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runDiscordSession(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Discord Gateway 已按配置关闭") + return + } + if err != nil { + logger.Warn("Discord Gateway 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runDiscordSession(ctx context.Context, cfg config.RobotDiscordConfig, h MessageHandler, logger *zap.Logger) error { + token := strings.TrimSpace(cfg.BotToken) + if !strings.HasPrefix(token, "Bot ") { + token = "Bot " + token + } + session, err := discordgo.New(token) + if err != nil { + return err + } + session.Identify.Intents = discordgo.IntentsGuildMessages | + discordgo.IntentsDirectMessages | + discordgo.IntentMessageContent + + session.AddHandler(func(s *discordgo.Session, m *discordgo.MessageCreate) { + if m == nil || m.Author == nil || m.Author.Bot { + return + } + text := strings.TrimSpace(m.Content) + if text == "" { + return + } + if m.GuildID != "" { + if !cfg.AllowGuildMessages { + return + } + if s.State.User == nil || !discordMentionsBot(m, s.State.User.ID) { + return + } + } + userID := discordSessionKey(m.GuildID, m.Author.ID) + logger.Info("Discord 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(discordPlatform, userID, text) + discordPostReply(s, m.ChannelID, reply, logger) + }) + + if err := session.Open(); err != nil { + return err + } + logger.Info("Discord Gateway 已连接,等待收消息") + defer session.Close() + + <-ctx.Done() + return ctx.Err() +} + +func discordMentionsBot(m *discordgo.MessageCreate, botUserID string) bool { + if m == nil || botUserID == "" { + return false + } + for _, mention := range m.Mentions { + if mention != nil && mention.ID == botUserID { + return true + } + } + return strings.Contains(m.Content, "<@"+botUserID+">") || strings.Contains(m.Content, "<@!"+botUserID+">") +} + +func discordSessionKey(guildID, userID string) string { + guildID = strings.TrimSpace(guildID) + userID = strings.TrimSpace(userID) + if guildID == "" { + return "u:" + userID + } + return "g:" + guildID + "|u:" + userID +} + +func discordPostReply(s *discordgo.Session, channelID, reply string, logger *zap.Logger) { + reply = trimReply(reply) + if reply == "" { + return + } + for _, chunk := range splitTextChunks(reply, discordMaxMessageRunes) { + if _, err := s.ChannelMessageSend(channelID, chunk); err != nil { + logger.Warn("Discord 发送回复失败", zap.String("channel", channelID), zap.Error(err)) + return + } + } +} diff --git a/internal/robot/ilink/client.go b/internal/robot/ilink/client.go new file mode 100644 index 00000000..00abafdb --- /dev/null +++ b/internal/robot/ilink/client.go @@ -0,0 +1,316 @@ +package ilink + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + DefaultBaseURL = "https://ilinkai.weixin.qq.com" + DefaultBotType = "3" + DefaultBotAgent = "CyberStrikeAI/1.0" + ILinkAppID = "bot" + QRLongPollTimeout = 35 * time.Second + APIDefaultTimeout = 15 * time.Second + GetUpdatesTimeout = 35 * time.Second +) + +// Client 微信 iLink Bot HTTP 客户端(与 @tencent-weixin/openclaw-weixin 协议兼容) +type Client struct { + BaseURL string + BotToken string + BotAgent string + ClientVersion uint32 + HTTP *http.Client +} + +func NewClient(baseURL, botToken, botAgent string, clientVersion uint32) *Client { + base := strings.TrimSpace(baseURL) + if base == "" { + base = DefaultBaseURL + } + agent := strings.TrimSpace(botAgent) + if agent == "" { + agent = DefaultBotAgent + } + return &Client{ + BaseURL: strings.TrimRight(base, "/"), + BotToken: strings.TrimSpace(botToken), + BotAgent: sanitizeBotAgent(agent), + ClientVersion: clientVersion, + HTTP: &http.Client{Timeout: 0}, + } +} + +// BuildClientVersion 将 semver 编码为 iLink-App-ClientVersion(0x00MMNNPP) +func BuildClientVersion(version string) uint32 { + parts := strings.Split(version, ".") + parse := func(i int) int { + if i >= len(parts) { + return 0 + } + n, _ := strconv.Atoi(strings.TrimSpace(parts[i])) + if n < 0 { + return 0 + } + return n + } + major := parse(0) & 0xff + minor := parse(1) & 0xff + patch := parse(2) & 0xff + return uint32((major << 16) | (minor << 8) | patch) +} + +type baseInfo struct { + ChannelVersion string `json:"channel_version"` + BotAgent string `json:"bot_agent"` +} + +func (c *Client) buildBaseInfo() baseInfo { + return baseInfo{ + ChannelVersion: "1.0.0", + BotAgent: c.BotAgent, + } +} + +func randomWechatUIN() string { + var b [4]byte + _, _ = rand.Read(b[:]) + u := uint32(b[0])<<24 | uint32(b[1])<<16 | uint32(b[2])<<8 | uint32(b[3]) + return base64.StdEncoding.EncodeToString([]byte(strconv.FormatUint(uint64(u), 10))) +} + +func (c *Client) commonHeaders() http.Header { + h := http.Header{} + h.Set("iLink-App-Id", ILinkAppID) + h.Set("iLink-App-ClientVersion", strconv.FormatUint(uint64(c.ClientVersion), 10)) + return h +} + +func (c *Client) authHeaders() http.Header { + h := c.commonHeaders() + h.Set("Content-Type", "application/json") + h.Set("AuthorizationType", "ilink_bot_token") + h.Set("X-WECHAT-UIN", randomWechatUIN()) + if c.BotToken != "" { + h.Set("Authorization", "Bearer "+c.BotToken) + } + return h +} + +func (c *Client) endpointURL(path string) (string, error) { + u, err := url.Parse(c.BaseURL + "/") + if err != nil { + return "", err + } + ref, err := url.Parse(path) + if err != nil { + return "", err + } + return u.ResolveReference(ref).String(), nil +} + +func (c *Client) doRequest(ctx context.Context, method, path string, body []byte, headers http.Header, timeout time.Duration) ([]byte, error) { + reqURL, err := c.endpointURL(path) + if err != nil { + return nil, err + } + var bodyReader io.Reader + if len(body) > 0 { + bodyReader = bytes.NewReader(body) + } + req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) + if err != nil { + return nil, err + } + for k, vs := range headers { + for _, v := range vs { + req.Header.Add(k, v) + } + } + client := c.HTTP + if client == nil { + client = http.DefaultClient + } + if timeout > 0 { + ctx2, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + req = req.WithContext(ctx2) + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, fmt.Errorf("ilink %s %s: %d %s", method, path, resp.StatusCode, string(raw)) + } + return raw, nil +} + +// QRCodeResponse 获取二维码响应 +type QRCodeResponse struct { + QRCode string `json:"qrcode"` + QRCodeImgContent string `json:"qrcode_img_content"` +} + +// GetBotQRCode 获取绑定二维码 +func (c *Client) GetBotQRCode(ctx context.Context, botType string, localTokenList []string) (*QRCodeResponse, error) { + if strings.TrimSpace(botType) == "" { + botType = DefaultBotType + } + body, _ := json.Marshal(map[string]interface{}{ + "local_token_list": localTokenList, + }) + path := "ilink/bot/get_bot_qrcode?bot_type=" + url.QueryEscape(botType) + raw, err := c.doRequest(ctx, http.MethodPost, path, body, c.authHeaders(), APIDefaultTimeout) + if err != nil { + return nil, err + } + var out QRCodeResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out, nil +} + +// QRStatusResponse 二维码状态轮询响应 +type QRStatusResponse struct { + Status string `json:"status"` + BotToken string `json:"bot_token"` + ILinkBotID string `json:"ilink_bot_id"` + ILinkUserID string `json:"ilink_user_id"` + BaseURL string `json:"baseurl"` + RedirectHost string `json:"redirect_host"` +} + +// GetQRCodeStatus 长轮询二维码扫码状态 +func (c *Client) GetQRCodeStatus(ctx context.Context, qrcode, verifyCode string) (*QRStatusResponse, error) { + path := "ilink/bot/get_qrcode_status?qrcode=" + url.QueryEscape(qrcode) + if verifyCode != "" { + path += "&verify_code=" + url.QueryEscape(verifyCode) + } + raw, err := c.doRequest(ctx, http.MethodGet, path, nil, c.commonHeaders(), QRLongPollTimeout) + if err != nil { + if ctx.Err() != nil { + return &QRStatusResponse{Status: "wait"}, nil + } + return &QRStatusResponse{Status: "wait"}, nil + } + var out QRStatusResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out, nil +} + +// MessageItem 消息内容项 +type MessageItem struct { + Type int `json:"type"` + TextItem *struct { + Text string `json:"text"` + } `json:"text_item,omitempty"` +} + +// WeixinMessage 入站消息 +type WeixinMessage struct { + FromUserID string `json:"from_user_id"` + MessageType int `json:"message_type"` + MessageState int `json:"message_state"` + ItemList []MessageItem `json:"item_list"` + ContextToken string `json:"context_token"` +} + +// GetUpdatesResponse 长轮询消息响应 +type GetUpdatesResponse struct { + Ret int `json:"ret"` + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + Msgs []WeixinMessage `json:"msgs"` + GetUpdatesBuf string `json:"get_updates_buf"` + LongPollingTimeoutMs int `json:"longpolling_timeout_ms"` +} + +// GetUpdates 长轮询获取新消息 +func (c *Client) GetUpdates(ctx context.Context, getUpdatesBuf string) (*GetUpdatesResponse, error) { + body, _ := json.Marshal(map[string]interface{}{ + "get_updates_buf": getUpdatesBuf, + "base_info": c.buildBaseInfo(), + }) + raw, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/getupdates", body, c.authHeaders(), GetUpdatesTimeout) + if err != nil { + if ctx.Err() != nil { + return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil + } + return &GetUpdatesResponse{Ret: 0, GetUpdatesBuf: getUpdatesBuf}, nil + } + var out GetUpdatesResponse + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return &out, nil +} + +// SendTextMessage 发送文本回复 +func (c *Client) SendTextMessage(ctx context.Context, toUserID, contextToken, text, clientID string) error { + if clientID == "" { + clientID = randomClientID() + } + payload := map[string]interface{}{ + "msg": map[string]interface{}{ + "to_user_id": toUserID, + "client_id": clientID, + "message_type": 2, + "message_state": 2, + "context_token": contextToken, + "item_list": []map[string]interface{}{ + {"type": 1, "text_item": map[string]string{"text": text}}, + }, + }, + "base_info": c.buildBaseInfo(), + } + body, _ := json.Marshal(payload) + _, err := c.doRequest(ctx, http.MethodPost, "ilink/bot/sendmessage", body, c.authHeaders(), APIDefaultTimeout) + return err +} + +func randomClientID() string { + var b [8]byte + _, _ = rand.Read(b[:]) + return fmt.Sprintf("%x", b) +} + +func sanitizeBotAgent(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return DefaultBotAgent + } + if len(raw) > 256 { + return raw[:256] + } + return raw +} + +// ExtractText 从消息中提取首条文本 +func ExtractText(msg WeixinMessage) string { + for _, item := range msg.ItemList { + if item.Type == 1 && item.TextItem != nil { + return strings.TrimSpace(item.TextItem.Text) + } + } + return "" +} diff --git a/internal/robot/ilink/qrcode_image.go b/internal/robot/ilink/qrcode_image.go new file mode 100644 index 00000000..0ef6521f --- /dev/null +++ b/internal/robot/ilink/qrcode_image.go @@ -0,0 +1,26 @@ +package ilink + +import ( + "encoding/base64" + "fmt" + "strings" + + "github.com/skip2/go-qrcode" +) + +// QRCodeDataURL 将扫码内容(一般为 liteapp 链接)编码为 PNG data URL,供 Web 端展示。 +// qrcode_img_content 不是图片直链,不能用作 。 +func QRCodeDataURL(content string, size int) (string, error) { + content = strings.TrimSpace(content) + if content == "" { + return "", fmt.Errorf("empty qr content") + } + if size <= 0 { + size = 256 + } + png, err := qrcode.Encode(content, qrcode.Medium, size) + if err != nil { + return "", err + } + return "data:image/png;base64," + base64.StdEncoding.EncodeToString(png), nil +} diff --git a/internal/robot/lark.go b/internal/robot/lark.go new file mode 100644 index 00000000..2cda0601 --- /dev/null +++ b/internal/robot/lark.go @@ -0,0 +1,141 @@ +package robot + +import ( + "context" + "encoding/json" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + lark "github.com/larksuite/oapi-sdk-go/v3" + larkcore "github.com/larksuite/oapi-sdk-go/v3/core" + "github.com/larksuite/oapi-sdk-go/v3/event/dispatcher" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + larkws "github.com/larksuite/oapi-sdk-go/v3/ws" + "go.uber.org/zap" +) + +const ( + larkReconnectInitial = 5 * time.Second // 首次重连间隔 + larkReconnectMax = 60 * time.Second // 最大重连间隔 +) + +type larkTextContent struct { + Text string `json:"text"` +} + +// StartLark 启动飞书长连接(无需公网),收到消息后调用 handler 并回复。 +// 断线(如笔记本睡眠、网络中断)后会自动重连;ctx 被取消时退出,便于配置变更时重启。 +func StartLark(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Lark + if !cfg.Enabled || cfg.AppID == "" || cfg.AppSecret == "" { + return + } + go runLarkLoop(ctx, cfg, robotsCfg.Session.StrictUserIdentityEnabled(), h, logger) +} + +// runLarkLoop 循环维持飞书长连接:断开且 ctx 未取消时按退避间隔重连。 +func runLarkLoop(ctx context.Context, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, logger *zap.Logger) { + backoff := larkReconnectInitial + for { + larkClient := lark.NewClient(cfg.AppID, cfg.AppSecret) + eventHandler := dispatcher.NewEventDispatcher("", "").OnP2MessageReceiveV1(func(ctx context.Context, event *larkim.P2MessageReceiveV1) error { + go handleLarkMessage(ctx, event, cfg, strictUserIdentity, h, larkClient, logger) + return nil + }) + wsClient := larkws.NewClient(cfg.AppID, cfg.AppSecret, + larkws.WithEventHandler(eventHandler), + larkws.WithLogLevel(larkcore.LogLevelInfo), + ) + logger.Info("飞书长连接正在连接…", zap.String("app_id", cfg.AppID)) + err := wsClient.Start(ctx) + if ctx.Err() != nil { + logger.Info("飞书长连接已按配置重启关闭") + return + } + if err != nil { + logger.Warn("飞书长连接断开(如睡眠/断网),将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + if backoff < larkReconnectMax { + backoff *= 2 + if backoff > larkReconnectMax { + backoff = larkReconnectMax + } + } + } + } +} + +func handleLarkMessage(ctx context.Context, event *larkim.P2MessageReceiveV1, cfg config.RobotLarkConfig, strictUserIdentity bool, h MessageHandler, client *lark.Client, logger *zap.Logger) { + if event == nil || event.Event == nil || event.Event.Message == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { + return + } + msg := event.Event.Message + msgType := larkcore.StringValue(msg.MessageType) + if msgType != larkim.MsgTypeText { + logger.Debug("飞书暂仅处理文本消息", zap.String("msg_type", msgType)) + return + } + var textBody larkTextContent + if err := json.Unmarshal([]byte(larkcore.StringValue(msg.Content)), &textBody); err != nil { + logger.Warn("飞书消息 Content 解析失败", zap.Error(err)) + return + } + text := strings.TrimSpace(textBody.Text) + if text == "" { + return + } + userID := resolveLarkUserID(event, cfg.AllowChatIDFallback && !strictUserIdentity) + if userID == "" { + logger.Warn("飞书消息缺少可用用户标识,已忽略") + return + } + messageID := larkcore.StringValue(msg.MessageId) + reply := h.HandleMessage("lark", userID, text) + contentBytes, _ := json.Marshal(larkTextContent{Text: reply}) + _, err := client.Im.Message.Reply(ctx, larkim.NewReplyMessageReqBuilder(). + MessageId(messageID). + Body(larkim.NewReplyMessageReqBodyBuilder(). + MsgType(larkim.MsgTypeText). + Content(string(contentBytes)). + Build()). + Build()) + if err != nil { + logger.Warn("飞书回复失败", zap.String("message_id", messageID), zap.Error(err)) + return + } + logger.Debug("飞书已回复", zap.String("message_id", messageID)) +} + +// resolveLarkUserID 提取飞书会话隔离键: +// tenant_key + 稳定用户标识(user_id/open_id/union_id);按配置可选 chat_id 兜底。 +func resolveLarkUserID(event *larkim.P2MessageReceiveV1, allowChatIDFallback bool) string { + if event == nil || event.Event == nil || event.Event.Sender == nil || event.Event.Sender.SenderId == nil { + return "" + } + tenantKey := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.TenantKey)) + if tenantKey == "" { + tenantKey = "default" + } + prefix := "t:" + tenantKey + "|" + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UserId)); id != "" { + return prefix + "u:" + id + } + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.OpenId)); id != "" { + return prefix + "o:" + id + } + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Sender.SenderId.UnionId)); id != "" { + return prefix + "n:" + id + } + if allowChatIDFallback && event.Event.Message != nil { + if id := strings.TrimSpace(larkcore.StringValue(event.Event.Message.ChatId)); id != "" { + return prefix + "c:" + id + } + } + return "" +} diff --git a/internal/robot/proactive.go b/internal/robot/proactive.go new file mode 100644 index 00000000..d600df8b --- /dev/null +++ b/internal/robot/proactive.go @@ -0,0 +1,192 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "github.com/bwmarrin/discordgo" + lark "github.com/larksuite/oapi-sdk-go/v3" + larkim "github.com/larksuite/oapi-sdk-go/v3/service/im/v1" + "github.com/slack-go/slack" +) + +// SendProactive sends a message without an inbound event. Platforms whose +// reply credentials are event-scoped deliberately return an error instead of +// pretending delivery succeeded. +func SendProactive(ctx context.Context, cfg config.RobotsConfig, platform, externalUserID, message string) error { + platform = strings.ToLower(strings.TrimSpace(platform)) + userID := robotIdentityUserPart(externalUserID) + if userID == "" { + return fmt.Errorf("invalid robot recipient") + } + switch platform { + case "telegram": + if !cfg.Telegram.Enabled || strings.TrimSpace(cfg.Telegram.BotToken) == "" { + return fmt.Errorf("telegram is not configured") + } + id, err := strconv.ParseInt(userID, 10, 64) + if err != nil { + return fmt.Errorf("invalid telegram user id: %w", err) + } + return telegramSendReply(ctx, nilSafeHTTPClient(), strings.TrimSpace(cfg.Telegram.BotToken), id, message) + case "slack": + if !cfg.Slack.Enabled || strings.TrimSpace(cfg.Slack.BotToken) == "" { + return fmt.Errorf("slack is not configured") + } + api := slack.New(strings.TrimSpace(cfg.Slack.BotToken)) + channel, _, _, err := api.OpenConversationContext(ctx, &slack.OpenConversationParameters{Users: []string{userID}}) + if err != nil { + return err + } + for _, chunk := range splitTextChunks(message, slackMaxMessageRunes) { + if _, _, err = api.PostMessageContext(ctx, channel.ID, slack.MsgOptionText(chunk, false)); err != nil { + return err + } + } + return nil + case "discord": + if !cfg.Discord.Enabled || strings.TrimSpace(cfg.Discord.BotToken) == "" { + return fmt.Errorf("discord is not configured") + } + token := strings.TrimSpace(cfg.Discord.BotToken) + if !strings.HasPrefix(token, "Bot ") { + token = "Bot " + token + } + session, err := discordgo.New(token) + if err != nil { + return err + } + channel, err := session.UserChannelCreate(userID) + if err != nil { + return err + } + for _, chunk := range splitTextChunks(message, discordMaxMessageRunes) { + if _, err = session.ChannelMessageSend(channel.ID, chunk); err != nil { + return err + } + } + return nil + case "wecom": + return sendWecomProactive(ctx, cfg.Wecom, userID, message) + case "lark": + return sendLarkProactive(ctx, cfg.Lark, externalUserID, message) + default: + return fmt.Errorf("platform %s does not support proactive alerts yet", platform) + } +} + +func SupportsProactive(platform string) bool { + switch strings.ToLower(strings.TrimSpace(platform)) { + case "telegram", "slack", "discord", "wecom", "lark": + return true + default: + return false + } +} + +func nilSafeHTTPClient() *http.Client { return &http.Client{Timeout: 15 * time.Second} } + +func sendWecomProactive(ctx context.Context, cfg config.RobotWecomConfig, userID, message string) error { + if !cfg.Enabled || strings.TrimSpace(cfg.CorpID) == "" || strings.TrimSpace(cfg.Secret) == "" || cfg.AgentID == 0 { + return fmt.Errorf("wecom proactive API is not configured") + } + tokenURL := "https://qyapi.weixin.qq.com/cgi-bin/gettoken?corpid=" + cfg.CorpID + "&corpsecret=" + cfg.Secret + req, err := http.NewRequestWithContext(ctx, http.MethodGet, tokenURL, nil) + if err != nil { + return err + } + resp, err := nilSafeHTTPClient().Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + var tokenResp struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + AccessToken string `json:"access_token"` + } + if err := json.Unmarshal(body, &tokenResp); err != nil { + return err + } + if tokenResp.ErrCode != 0 || tokenResp.AccessToken == "" { + return fmt.Errorf("wecom token: %s", tokenResp.ErrMsg) + } + payload, _ := json.Marshal(map[string]interface{}{ + "touser": userID, "msgtype": "text", "agentid": cfg.AgentID, + "text": map[string]string{"content": message}, "safe": 0, + }) + sendReq, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token="+tokenResp.AccessToken, bytes.NewReader(payload)) + if err != nil { + return err + } + sendReq.Header.Set("Content-Type", "application/json") + sendResp, err := nilSafeHTTPClient().Do(sendReq) + if err != nil { + return err + } + defer sendResp.Body.Close() + result, _ := io.ReadAll(sendResp.Body) + var parsed struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + if err := json.Unmarshal(result, &parsed); err != nil { + return err + } + if parsed.ErrCode != 0 { + return fmt.Errorf("wecom send: %s", parsed.ErrMsg) + } + return nil +} + +func sendLarkProactive(ctx context.Context, cfg config.RobotLarkConfig, identity, message string) error { + if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.AppSecret) == "" { + return fmt.Errorf("lark is not configured") + } + receiveIDType, receiveID := "user_id", robotIdentityUserPart(identity) + if idx := strings.LastIndex(identity, "|o:"); idx >= 0 { + receiveIDType, receiveID = "open_id", strings.TrimSpace(identity[idx+3:]) + } + if idx := strings.LastIndex(identity, "|n:"); idx >= 0 { + receiveIDType, receiveID = "union_id", strings.TrimSpace(identity[idx+3:]) + } + if receiveID == "" { + return fmt.Errorf("invalid lark recipient") + } + content, _ := json.Marshal(larkTextContent{Text: message}) + client := lark.NewClient(cfg.AppID, cfg.AppSecret) + resp, err := client.Im.Message.Create(ctx, larkim.NewCreateMessageReqBuilder(). + ReceiveIdType(receiveIDType). + Body(larkim.NewCreateMessageReqBodyBuilder().ReceiveId(receiveID).MsgType(larkim.MsgTypeText).Content(string(content)).Build()).Build()) + if err != nil { + return err + } + if resp == nil || !resp.Success() { + return fmt.Errorf("lark send failed") + } + return nil +} + +func robotIdentityUserPart(identity string) string { + identity = strings.TrimSpace(identity) + if i := strings.LastIndex(identity, "|u:"); i >= 0 { + return strings.TrimSpace(identity[i+3:]) + } + if strings.HasPrefix(identity, "u:") { + return strings.TrimSpace(identity[2:]) + } + return identity +} diff --git a/internal/robot/qq.go b/internal/robot/qq.go new file mode 100644 index 00000000..489d8909 --- /dev/null +++ b/internal/robot/qq.go @@ -0,0 +1,209 @@ +package robot + +import ( + "context" + "strings" + "sync" + + "cyberstrike-ai/internal/config" + + "github.com/tencent-connect/botgo" + "github.com/tencent-connect/botgo/dto" + "github.com/tencent-connect/botgo/event" + "github.com/tencent-connect/botgo/openapi" + "github.com/tencent-connect/botgo/token" + "go.uber.org/zap" +) + +const ( + qqPlatform = "qq" + qqMaxMessageRunes = 3500 +) + +var ( + qqHandlerMu sync.Mutex + qqHandler MessageHandler + qqLogger *zap.Logger + qqAPI openapi.OpenAPI +) + +// StartQQ 启动 QQ 机器人 WebSocket(C2C 与群 @,出站连接,无需公网回调)。 +func StartQQ(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.QQ + if !cfg.Enabled || strings.TrimSpace(cfg.AppID) == "" || strings.TrimSpace(cfg.ClientSecret) == "" { + return + } + go runQQLoop(ctx, cfg, h, logger) +} + +func runQQLoop(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + if ctx.Err() != nil { + logger.Info("QQ 机器人 WebSocket 已按配置关闭") + return + } + err := runQQSession(ctx, cfg, h, logger) + if ctx.Err() != nil { + return + } + if err != nil { + logger.Warn("QQ 机器人 WebSocket 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runQQSession(ctx context.Context, cfg config.RobotQQConfig, h MessageHandler, logger *zap.Logger) error { + appID := strings.TrimSpace(cfg.AppID) + secret := strings.TrimSpace(cfg.ClientSecret) + credentials := &token.QQBotCredentials{AppID: appID, AppSecret: secret} + tokenSource := token.NewQQBotTokenSource(credentials) + + if err := token.StartRefreshAccessToken(ctx, tokenSource); err != nil { + return err + } + + var api openapi.OpenAPI + if cfg.Sandbox { + api = botgo.NewSandboxOpenAPI(appID, tokenSource) + } else { + api = botgo.NewOpenAPI(appID, tokenSource) + } + + qqHandlerMu.Lock() + qqHandler = h + qqLogger = logger + qqAPI = api + qqHandlerMu.Unlock() + defer func() { + qqHandlerMu.Lock() + qqHandler = nil + qqLogger = nil + qqAPI = nil + qqHandlerMu.Unlock() + }() + + intents := event.RegisterHandlers( + event.C2CMessageEventHandler(handleQQC2CMessage), + event.GroupATMessageEventHandler(handleQQGroupATMessage), + ) + + wsInfo, err := api.WS(ctx, nil, "") + if err != nil { + return err + } + logger.Info("QQ 机器人 WebSocket 正在连接…", zap.String("app_id", appID), zap.Bool("sandbox", cfg.Sandbox)) + + done := make(chan error, 1) + go func() { + done <- botgo.NewSessionManager().Start(wsInfo, tokenSource, &intents) + }() + + select { + case <-ctx.Done(): + return ctx.Err() + case err := <-done: + if err != nil { + return err + } + return nil + } +} + +func handleQQC2CMessage(payload *dto.WSPayload, data *dto.WSC2CMessageData) error { + if data == nil || data.Author == nil { + return nil + } + text := strings.TrimSpace(data.Content) + if text == "" { + return nil + } + userOpenID := strings.TrimSpace(data.Author.ID) + if userOpenID == "" { + return nil + } + userID := "u:" + userOpenID + qqHandlerMu.Lock() + h := qqHandler + logger := qqLogger + api := qqAPI + qqHandlerMu.Unlock() + if h == nil || api == nil { + return nil + } + logger.Info("QQ 收到 C2C 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(qqPlatform, userID, text) + return qqPostC2CReply(context.Background(), api, userOpenID, payload, data.ID, reply, logger) +} + +func handleQQGroupATMessage(payload *dto.WSPayload, data *dto.WSGroupATMessageData) error { + if data == nil || data.Author == nil { + return nil + } + text := strings.TrimSpace(data.Content) + if text == "" { + return nil + } + userOpenID := strings.TrimSpace(data.Author.ID) + groupID := strings.TrimSpace(data.GroupID) + if userOpenID == "" { + return nil + } + userID := "g:" + groupID + "|u:" + userOpenID + qqHandlerMu.Lock() + h := qqHandler + logger := qqLogger + api := qqAPI + qqHandlerMu.Unlock() + if h == nil || api == nil { + return nil + } + logger.Info("QQ 收到群 @ 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(qqPlatform, userID, text) + return qqPostGroupReply(context.Background(), api, groupID, payload, data.ID, reply, logger) +} + +func qqPostC2CReply(ctx context.Context, api openapi.OpenAPI, userOpenID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { + msg := &dto.MessageToCreate{ + Content: chunk, + MsgID: msgID, + } + if payload != nil && payload.EventID != "" { + msg.EventID = payload.EventID + } + if _, err := api.PostC2CMessage(ctx, userOpenID, msg); err != nil { + logger.Warn("QQ 发送 C2C 回复失败", zap.String("to", userOpenID), zap.Error(err)) + return err + } + } + return nil +} + +func qqPostGroupReply(ctx context.Context, api openapi.OpenAPI, groupID string, payload *dto.WSPayload, msgID, reply string, logger *zap.Logger) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, qqMaxMessageRunes) { + msg := &dto.MessageToCreate{ + Content: chunk, + MsgID: msgID, + } + if payload != nil && payload.EventID != "" { + msg.EventID = payload.EventID + } + if _, err := api.PostGroupMessage(ctx, groupID, msg); err != nil { + logger.Warn("QQ 发送群消息回复失败", zap.String("group", groupID), zap.Error(err)) + return err + } + } + return nil +} diff --git a/internal/robot/reconnect.go b/internal/robot/reconnect.go new file mode 100644 index 00000000..4aee6c90 --- /dev/null +++ b/internal/robot/reconnect.go @@ -0,0 +1,38 @@ +package robot + +import ( + "context" + "time" +) + +const ( + reconnectInitial = 5 * time.Second + reconnectMax = 60 * time.Second +) + +func waitReconnect(ctx context.Context, backoff *time.Duration) bool { + if ctx.Err() != nil { + return false + } + select { + case <-ctx.Done(): + return false + case <-time.After(*backoff): + if *backoff < reconnectMax { + *backoff *= 2 + if *backoff > reconnectMax { + *backoff = reconnectMax + } + } + return true + } +} + +func bumpBackoff(backoff *time.Duration) { + if *backoff < reconnectMax { + *backoff *= 2 + if *backoff > reconnectMax { + *backoff = reconnectMax + } + } +} diff --git a/internal/robot/slack.go b/internal/robot/slack.go new file mode 100644 index 00000000..1e30a26a --- /dev/null +++ b/internal/robot/slack.go @@ -0,0 +1,135 @@ +package robot + +import ( + "context" + "strings" + + "cyberstrike-ai/internal/config" + + "github.com/slack-go/slack" + "github.com/slack-go/slack/slackevents" + "github.com/slack-go/slack/socketmode" + "go.uber.org/zap" +) + +const ( + slackPlatform = "slack" + slackMaxMessageRunes = 3900 +) + +// StartSlack 启动 Slack Socket Mode(出站 WebSocket,无需公网回调)。 +func StartSlack(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Slack + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" || strings.TrimSpace(cfg.AppToken) == "" { + return + } + go runSlackLoop(ctx, cfg, h, logger) +} + +func runSlackLoop(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runSlackSocket(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Slack Socket Mode 已按配置关闭") + return + } + if err != nil { + logger.Warn("Slack Socket Mode 异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runSlackSocket(ctx context.Context, cfg config.RobotSlackConfig, h MessageHandler, logger *zap.Logger) error { + api := slack.New( + strings.TrimSpace(cfg.BotToken), + slack.OptionAppLevelToken(strings.TrimSpace(cfg.AppToken)), + ) + client := socketmode.New(api) + logger.Info("Slack Socket Mode 正在连接…") + + go func() { + for evt := range client.Events { + switch evt.Type { + case socketmode.EventTypeEventsAPI: + eventsAPIEvent, ok := evt.Data.(slackevents.EventsAPIEvent) + if !ok { + continue + } + client.Ack(*evt.Request) + if eventsAPIEvent.Type != slackevents.CallbackEvent { + continue + } + switch ev := eventsAPIEvent.InnerEvent.Data.(type) { + case *slackevents.MessageEvent: + handleSlackMessage(ctx, api, eventsAPIEvent.TeamID, ev, h, logger) + case *slackevents.AppMentionEvent: + handleSlackAppMention(ctx, api, eventsAPIEvent.TeamID, ev, h, logger) + } + case socketmode.EventTypeConnecting: + logger.Info("Slack Socket Mode 正在连接…") + case socketmode.EventTypeConnected: + logger.Info("Slack Socket Mode 已连接,等待收消息") + } + } + }() + + return client.RunContext(ctx) +} + +func handleSlackMessage(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.MessageEvent, h MessageHandler, logger *zap.Logger) { + if ev == nil || ev.BotID != "" || ev.SubType != "" { + return + } + if ev.ChannelType != "im" { + return + } + text := strings.TrimSpace(ev.Text) + if text == "" { + return + } + userID := slackSessionKey(teamID, ev.User) + logger.Info("Slack 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(slackPlatform, userID, text) + slackPostReply(ctx, api, ev.Channel, reply, logger) +} + +func handleSlackAppMention(ctx context.Context, api *slack.Client, teamID string, ev *slackevents.AppMentionEvent, h MessageHandler, logger *zap.Logger) { + if ev == nil || ev.BotID != "" { + return + } + text := strings.TrimSpace(ev.Text) + if text == "" { + return + } + userID := slackSessionKey(teamID, ev.User) + logger.Info("Slack 收到 @ 消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(slackPlatform, userID, text) + slackPostReply(ctx, api, ev.Channel, reply, logger) +} + +func slackSessionKey(teamID, userID string) string { + teamID = strings.TrimSpace(teamID) + userID = strings.TrimSpace(userID) + if teamID == "" { + teamID = "default" + } + return "t:" + teamID + "|u:" + userID +} + +func slackPostReply(ctx context.Context, api *slack.Client, channel, reply string, logger *zap.Logger) { + reply = trimReply(reply) + if reply == "" { + return + } + for _, chunk := range splitTextChunks(reply, slackMaxMessageRunes) { + _, _, err := api.PostMessageContext(ctx, channel, slack.MsgOptionText(chunk, false)) + if err != nil { + logger.Warn("Slack 发送回复失败", zap.String("channel", channel), zap.Error(err)) + return + } + } +} diff --git a/internal/robot/split.go b/internal/robot/split.go new file mode 100644 index 00000000..c5425b84 --- /dev/null +++ b/internal/robot/split.go @@ -0,0 +1,29 @@ +package robot + +import "strings" + +// splitTextChunks splits text into chunks no longer than maxRunes (rune count). +func splitTextChunks(text string, maxRunes int) []string { + text = strings.TrimSpace(text) + if text == "" || maxRunes <= 0 { + return nil + } + runes := []rune(text) + if len(runes) <= maxRunes { + return []string{text} + } + var out []string + for len(runes) > 0 { + end := maxRunes + if end > len(runes) { + end = len(runes) + } + out = append(out, string(runes[:end])) + runes = runes[end:] + } + return out +} + +func trimReply(s string) string { + return strings.TrimSpace(s) +} diff --git a/internal/robot/telegram.go b/internal/robot/telegram.go new file mode 100644 index 00000000..f8712d57 --- /dev/null +++ b/internal/robot/telegram.go @@ -0,0 +1,262 @@ +package robot + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" + + "cyberstrike-ai/internal/config" + + "go.uber.org/zap" +) + +const ( + telegramPlatform = "telegram" + telegramAPIBase = "https://api.telegram.org" + telegramLongPollSec = 30 + telegramMaxMessageRunes = 4096 +) + +type telegramUpdate struct { + UpdateID int `json:"update_id"` + Message *telegramMessage `json:"message"` +} + +type telegramMessage struct { + MessageID int64 `json:"message_id"` + Chat telegramChat `json:"chat"` + From *telegramUser `json:"from"` + Text string `json:"text"` + Entities []telegramEntity `json:"entities"` +} + +type telegramChat struct { + ID int64 `json:"id"` + Type string `json:"type"` + Title string `json:"title"` +} + +type telegramUser struct { + ID int64 `json:"id"` + Username string `json:"username"` + IsBot bool `json:"is_bot"` +} + +type telegramEntity struct { + Type string `json:"type"` + Offset int `json:"offset"` + Length int `json:"length"` +} + +type telegramGetUpdatesResp struct { + OK bool `json:"ok"` + Result []telegramUpdate `json:"result"` + Description string `json:"description"` +} + +type telegramBotMe struct { + OK bool `json:"ok"` + Result struct { + ID int64 `json:"id"` + Username string `json:"username"` + } `json:"result"` +} + +// StartTelegram 启动 Telegram Bot 长轮询(getUpdates,无需公网回调)。 +func StartTelegram(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, logger *zap.Logger) { + cfg := robotsCfg.Telegram + if !cfg.Enabled || strings.TrimSpace(cfg.BotToken) == "" { + return + } + go runTelegramLoop(ctx, cfg, h, logger) +} + +func runTelegramLoop(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) { + backoff := reconnectInitial + for { + err := runTelegramPoll(ctx, cfg, h, logger) + if ctx.Err() != nil { + logger.Info("Telegram 长轮询已按配置关闭") + return + } + if err != nil { + logger.Warn("Telegram 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + if !waitReconnect(ctx, &backoff) { + return + } + } +} + +func runTelegramPoll(ctx context.Context, cfg config.RobotTelegramConfig, h MessageHandler, logger *zap.Logger) error { + token := strings.TrimSpace(cfg.BotToken) + botUsername := strings.TrimSpace(cfg.BotUsername) + if botUsername == "" { + if name, err := telegramGetMe(ctx, token); err != nil { + logger.Warn("Telegram getMe 失败", zap.Error(err)) + } else { + botUsername = name + } + } + offset := cfg.UpdateOffset + logger.Info("Telegram 长轮询已启动", zap.String("bot", botUsername)) + client := &http.Client{Timeout: telegramLongPollSec*time.Second + 10*time.Second} + + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + updates, err := telegramGetUpdates(ctx, client, token, offset) + if err != nil { + return err + } + for _, u := range updates { + next := int64(u.UpdateID) + 1 + if next > offset { + offset = next + } + if u.Message == nil || u.Message.From == nil || u.Message.From.IsBot { + continue + } + text := strings.TrimSpace(u.Message.Text) + if text == "" { + continue + } + chatType := strings.ToLower(strings.TrimSpace(u.Message.Chat.Type)) + if chatType != "private" { + if !cfg.AllowGroupMessages { + continue + } + if botUsername != "" && !telegramMentionsBot(text, u.Message.Entities, botUsername) { + continue + } + } + userID := telegramSessionKey(chatType, u.Message.Chat.ID, u.Message.From.ID) + logger.Info("Telegram 收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(telegramPlatform, userID, text) + if err := telegramSendReply(ctx, client, token, u.Message.Chat.ID, reply); err != nil { + logger.Warn("Telegram 发送回复失败", zap.String("to", userID), zap.Error(err)) + } + } + } +} + +func telegramSessionKey(chatType string, chatID, fromUserID int64) string { + if chatType == "private" { + return fmt.Sprintf("u:%d", fromUserID) + } + return fmt.Sprintf("g:%d|u:%d", chatID, fromUserID) +} + +func telegramMentionsBot(text string, entities []telegramEntity, botUsername string) bool { + needle := "@" + strings.TrimPrefix(strings.ToLower(botUsername), "@") + lower := strings.ToLower(text) + if strings.Contains(lower, needle) { + return true + } + for _, e := range entities { + if e.Type != "mention" { + continue + } + if e.Offset < 0 || e.Length <= 0 || e.Offset+e.Length > len(text) { + continue + } + mention := strings.ToLower(text[e.Offset : e.Offset+e.Length]) + if mention == needle { + return true + } + } + return false +} + +func telegramAPIURL(token, method string) string { + return fmt.Sprintf("%s/bot%s/%s", telegramAPIBase, token, method) +} + +func telegramGetMe(ctx context.Context, token string) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, telegramAPIURL(token, "getMe"), nil) + if err != nil { + return "", err + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + var parsed telegramBotMe + if err := json.Unmarshal(body, &parsed); err != nil { + return "", err + } + if !parsed.OK { + return "", fmt.Errorf("getMe failed: %s", string(body)) + } + return parsed.Result.Username, nil +} + +func telegramGetUpdates(ctx context.Context, client *http.Client, token string, offset int64) ([]telegramUpdate, error) { + url := fmt.Sprintf("%s?timeout=%d&allowed_updates=%s", telegramAPIURL(token, "getUpdates"), telegramLongPollSec, `["message"]`) + if offset > 0 { + url += fmt.Sprintf("&offset=%d", offset) + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + var parsed telegramGetUpdatesResp + if err := json.Unmarshal(body, &parsed); err != nil { + return nil, err + } + if !parsed.OK { + if parsed.Description != "" { + return nil, fmt.Errorf("getUpdates: %s", parsed.Description) + } + return nil, fmt.Errorf("getUpdates failed: %s", string(body)) + } + return parsed.Result, nil +} + +func telegramSendReply(ctx context.Context, client *http.Client, token string, chatID int64, reply string) error { + reply = trimReply(reply) + if reply == "" { + return nil + } + for _, chunk := range splitTextChunks(reply, telegramMaxMessageRunes) { + payload := map[string]interface{}{ + "chat_id": chatID, + "text": chunk, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, telegramAPIURL(token, "sendMessage"), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("sendMessage status %d", resp.StatusCode) + } + } + return nil +} diff --git a/internal/robot/wechat.go b/internal/robot/wechat.go new file mode 100644 index 00000000..17d50404 --- /dev/null +++ b/internal/robot/wechat.go @@ -0,0 +1,96 @@ +package robot + +import ( + "context" + "strings" + "time" + + "cyberstrike-ai/internal/config" + "cyberstrike-ai/internal/robot/ilink" + + "go.uber.org/zap" +) + +const ( + wechatReconnectInitial = 5 * time.Second + wechatReconnectMax = 60 * time.Second + wechatPlatform = "wechat" +) + +// StartWechat 启动微信 iLink 长轮询(无需公网回调),收到消息后调用 handler 并回复。 +func StartWechat(ctx context.Context, robotsCfg config.RobotsConfig, h MessageHandler, appVersion string, logger *zap.Logger) { + cfg := robotsCfg.Wechat + if !cfg.Enabled || cfg.BotToken == "" { + return + } + go runWechatLoop(ctx, cfg, h, appVersion, logger) +} + +func runWechatLoop(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) { + backoff := wechatReconnectInitial + for { + err := runWechatPoll(ctx, cfg, h, appVersion, logger) + if ctx.Err() != nil { + logger.Info("微信 iLink 长轮询已按配置关闭") + return + } + if err != nil { + logger.Warn("微信 iLink 长轮询异常,将自动重连", zap.Error(err), zap.Duration("retry_after", backoff)) + } + select { + case <-ctx.Done(): + return + case <-time.After(backoff): + if backoff < wechatReconnectMax { + backoff *= 2 + if backoff > wechatReconnectMax { + backoff = wechatReconnectMax + } + } + } + } +} + +func runWechatPoll(ctx context.Context, cfg config.RobotWechatConfig, h MessageHandler, appVersion string, logger *zap.Logger) error { + client := ilink.NewClient(cfg.BaseURL, cfg.BotToken, cfg.BotAgent, ilink.BuildClientVersion(appVersion)) + buf := cfg.GetUpdatesBuf + logger.Info("微信 iLink 长轮询已启动", zap.String("ilink_bot_id", cfg.ILinkBotID)) + for { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + resp, err := client.GetUpdates(ctx, buf) + if err != nil { + return err + } + if resp.ErrCode != 0 && resp.Ret != 0 { + logger.Warn("微信 getUpdates 返回错误", zap.Int("errcode", resp.ErrCode), zap.String("errmsg", resp.ErrMsg)) + } + if resp.GetUpdatesBuf != "" { + buf = resp.GetUpdatesBuf + } + for _, msg := range resp.Msgs { + if msg.MessageType != 1 { + continue + } + text := ilink.ExtractText(msg) + if text == "" { + continue + } + userID := strings.TrimSpace(msg.FromUserID) + if userID == "" { + continue + } + logger.Info("微信收到消息", zap.String("from", userID), zap.String("content", text)) + reply := h.HandleMessage(wechatPlatform, userID, text) + if strings.TrimSpace(reply) == "" { + continue + } + if err := client.SendTextMessage(ctx, userID, msg.ContextToken, reply, ""); err != nil { + logger.Warn("微信发送回复失败", zap.String("to", userID), zap.Error(err)) + } + } + } +} diff --git a/internal/workflow/agent_subgraph.go b/internal/workflow/agent_subgraph.go new file mode 100644 index 00000000..5e5d1286 --- /dev/null +++ b/internal/workflow/agent_subgraph.go @@ -0,0 +1,48 @@ +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 +} diff --git a/internal/workflow/bindings.go b/internal/workflow/bindings.go new file mode 100644 index 00000000..f5003cc2 --- /dev/null +++ b/internal/workflow/bindings.go @@ -0,0 +1,153 @@ +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 | + 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 +} diff --git a/internal/workflow/checkpoint_store.go b/internal/workflow/checkpoint_store.go new file mode 100644 index 00000000..5d254ac0 --- /dev/null +++ b/internal/workflow/checkpoint_store.go @@ -0,0 +1,69 @@ +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) +} diff --git a/internal/workflow/draft_generator.go b/internal/workflow/draft_generator.go new file mode 100644 index 00000000..bfb8b680 --- /dev/null +++ b/internal/workflow/draft_generator.go @@ -0,0 +1,782 @@ +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()) +} diff --git a/internal/workflow/draft_generator_test.go b/internal/workflow/draft_generator_test.go new file mode 100644 index 00000000..e5ec61e7 --- /dev/null +++ b/internal/workflow/draft_generator_test.go @@ -0,0 +1,213 @@ +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) + } +} diff --git a/internal/workflow/dry_run.go b/internal/workflow/dry_run.go new file mode 100644 index 00000000..0a79f0bf --- /dev/null +++ b/internal/workflow/dry_run.go @@ -0,0 +1,173 @@ +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 +} diff --git a/internal/workflow/eino_branch.go b/internal/workflow/eino_branch.go new file mode 100644 index 00000000..1b3b9b13 --- /dev/null +++ b/internal/workflow/eino_branch.go @@ -0,0 +1,107 @@ +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 +} diff --git a/internal/workflow/eino_callbacks.go b/internal/workflow/eino_callbacks.go new file mode 100644 index 00000000..e4761c89 --- /dev/null +++ b/internal/workflow/eino_callbacks.go @@ -0,0 +1,22 @@ +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, + }) +} diff --git a/internal/workflow/eino_compile.go b/internal/workflow/eino_compile.go new file mode 100644 index 00000000..a2ca9881 --- /dev/null +++ b/internal/workflow/eino_compile.go @@ -0,0 +1,243 @@ +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 +} diff --git a/internal/workflow/eino_compile_test.go b/internal/workflow/eino_compile_test.go new file mode 100644 index 00000000..691bdbce --- /dev/null +++ b/internal/workflow/eino_compile_test.go @@ -0,0 +1,412 @@ +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") + } +} diff --git a/internal/workflow/eino_runtime.go b/internal/workflow/eino_runtime.go new file mode 100644 index 00000000..904b6c5a --- /dev/null +++ b/internal/workflow/eino_runtime.go @@ -0,0 +1,64 @@ +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 +} diff --git a/internal/workflow/engine.go b/internal/workflow/engine.go new file mode 100644 index 00000000..888ca16d --- /dev/null +++ b/internal/workflow/engine.go @@ -0,0 +1,239 @@ +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 +} diff --git a/internal/workflow/errors.go b/internal/workflow/errors.go new file mode 100644 index 00000000..840b9b0e --- /dev/null +++ b/internal/workflow/errors.go @@ -0,0 +1,24 @@ +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) +} diff --git a/internal/workflow/expression.go b/internal/workflow/expression.go new file mode 100644 index 00000000..4a3d2b6d --- /dev/null +++ b/internal/workflow/expression.go @@ -0,0 +1,186 @@ +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 +} diff --git a/internal/workflow/expression_join_test.go b/internal/workflow/expression_join_test.go new file mode 100644 index 00000000..325a9159 --- /dev/null +++ b/internal/workflow/expression_join_test.go @@ -0,0 +1,107 @@ +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() +} diff --git a/internal/workflow/graph_types.go b/internal/workflow/graph_types.go new file mode 100644 index 00000000..8e49be11 --- /dev/null +++ b/internal/workflow/graph_types.go @@ -0,0 +1,153 @@ +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" +} diff --git a/internal/workflow/hitl_wait.go b/internal/workflow/hitl_wait.go new file mode 100644 index 00000000..624cdf5b --- /dev/null +++ b/internal/workflow/hitl_wait.go @@ -0,0 +1,119 @@ +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 + } + } + } +} diff --git a/internal/workflow/join.go b/internal/workflow/join.go new file mode 100644 index 00000000..2d720ef5 --- /dev/null +++ b/internal/workflow/join.go @@ -0,0 +1,117 @@ +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 +} diff --git a/internal/workflow/jsonpath.go b/internal/workflow/jsonpath.go new file mode 100644 index 00000000..c379d299 --- /dev/null +++ b/internal/workflow/jsonpath.go @@ -0,0 +1,115 @@ +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 +} diff --git a/internal/workflow/metrics.go b/internal/workflow/metrics.go new file mode 100644 index 00000000..9eb8285a --- /dev/null +++ b/internal/workflow/metrics.go @@ -0,0 +1,57 @@ +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) + } + } + } +} diff --git a/internal/workflow/node_exec.go b/internal/workflow/node_exec.go new file mode 100644 index 00000000..1e3b0fda --- /dev/null +++ b/internal/workflow/node_exec.go @@ -0,0 +1,153 @@ +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), + }) + } +} diff --git a/internal/workflow/nodes.go b/internal/workflow/nodes.go new file mode 100644 index 00000000..eb0342e2 --- /dev/null +++ b/internal/workflow/nodes.go @@ -0,0 +1,333 @@ +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", "" +} diff --git a/internal/workflow/package/exporter.go b/internal/workflow/package/exporter.go new file mode 100644 index 00000000..46494354 --- /dev/null +++ b/internal/workflow/package/exporter.go @@ -0,0 +1,98 @@ +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 +} diff --git a/internal/workflow/package/exporter_test.go b/internal/workflow/package/exporter_test.go new file mode 100644 index 00000000..4b13fa48 --- /dev/null +++ b/internal/workflow/package/exporter_test.go @@ -0,0 +1,58 @@ +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) + } +} diff --git a/internal/workflow/package/inspector.go b/internal/workflow/package/inspector.go new file mode 100644 index 00000000..7d533563 --- /dev/null +++ b/internal/workflow/package/inspector.go @@ -0,0 +1,225 @@ +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 +} diff --git a/internal/workflow/package/inspector_test.go b/internal/workflow/package/inspector_test.go new file mode 100644 index 00000000..4847eabd --- /dev/null +++ b/internal/workflow/package/inspector_test.go @@ -0,0 +1,154 @@ +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() +} diff --git a/internal/workflow/package/manifest.go b/internal/workflow/package/manifest.go new file mode 100644 index 00000000..a84e263b --- /dev/null +++ b/internal/workflow/package/manifest.go @@ -0,0 +1,78 @@ +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) +} diff --git a/internal/workflow/runner.go b/internal/workflow/runner.go new file mode 100644 index 00000000..e5b3876d --- /dev/null +++ b/internal/workflow/runner.go @@ -0,0 +1,223 @@ +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 +} diff --git a/internal/workflow/state.go b/internal/workflow/state.go new file mode 100644 index 00000000..8b3a0376 --- /dev/null +++ b/internal/workflow/state.go @@ -0,0 +1,214 @@ +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()) +} diff --git a/internal/workflow/structured_outputs.go b/internal/workflow/structured_outputs.go new file mode 100644 index 00000000..1c3200a5 --- /dev/null +++ b/internal/workflow/structured_outputs.go @@ -0,0 +1,150 @@ +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, + }) +} diff --git a/internal/workflow/tool_output_budget_test.go b/internal/workflow/tool_output_budget_test.go new file mode 100644 index 00000000..d66d27eb --- /dev/null +++ b/internal/workflow/tool_output_budget_test.go @@ -0,0 +1,23 @@ +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) + } +} diff --git a/internal/workflow/types.go b/internal/workflow/types.go new file mode 100644 index 00000000..218c024b --- /dev/null +++ b/internal/workflow/types.go @@ -0,0 +1,74 @@ +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) +} diff --git a/internal/workflow/validation.go b/internal/workflow/validation.go new file mode 100644 index 00000000..70502778 --- /dev/null +++ b/internal/workflow/validation.go @@ -0,0 +1,366 @@ +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 +}