Files
ai-agent/workflow/service/flow/flow_ws_exec.go
T

448 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package flow
import (
"ai-agent/workflow/consts/flow"
flowDao "ai-agent/workflow/dao/flow"
sessionDao "ai-agent/workflow/dao/session"
flowDto "ai-agent/workflow/model/dto/flow"
sessionDto "ai-agent/workflow/model/dto/session"
"ai-agent/workflow/model/entity"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"time"
"gitea.redpowerfuture.com/red-future/common/utils"
wsCommon "gitea.redpowerfuture.com/red-future/common/websocket"
"github.com/cloudwego/eino/compose"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/glog"
"github.com/gogf/gf/v2/util/gconv"
"github.com/google/uuid"
)
// ====================== WebSocket 服务器 ======================
func init() {
// 工作流消息处理器注册在统一的 SessionWsService(见 ws_server.go)上:
// 首次连接仅升级,连接后按消息 type 路由,不再建连时区分普通对话/工作流
SessionWsService.OnMessage("workflow", handleExecute)
SessionWsService.OnMessage("workflow_cancel", handleCancel)
}
// defaultSessionName 工作流执行但查不到流程名时,会话的兜底名称
const defaultSessionName = "工作流执行"
// errWorkflowTerminated 前端终止工作流执行时的错误标记(写入 exec_workflow.error_message
var errWorkflowTerminated = "用户已终止执行"
// ====================== 进度上报 ======================
type wsProgressCtxKey struct{}
// ProgressReporter 节点执行进度回调接口
type ProgressReporter interface {
ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int)
ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int)
}
// GetProgressReporter 从context中获取进度上报器
func GetProgressReporter(ctx context.Context) ProgressReporter {
if reporter, ok := ctx.Value(wsProgressCtxKey{}).(ProgressReporter); ok {
return reporter
}
return nil
}
type wsProgressReporter struct {
conn *wsCommon.WsConnection
mu sync.Mutex
}
func (r *wsProgressReporter) ReportStart(nodeId, nodeName string, nodeIndex, nodeCount int) {
if r.conn.IsClosed() {
return
}
r.mu.Lock()
msg := &wsCommon.WsPushMsg{
Type: "node_start",
Message: fmt.Sprintf("开始执行(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
}
r.mu.Unlock()
_ = writeJSON(r.conn, msg)
}
func (r *wsProgressReporter) ReportComplete(nodeId, nodeName string, nodeIndex, nodeCount int) {
if r.conn.IsClosed() {
return
}
r.mu.Lock()
msg := &wsCommon.WsPushMsg{
Type: "node_complete",
Message: fmt.Sprintf("执行完成(%d/%d): %s ", nodeIndex, nodeCount, nodeName),
Data: map[string]interface{}{
"nodeId": nodeId, "nodeName": nodeName,
"nodeIndex": nodeIndex, "nodeCount": nodeCount,
},
}
r.mu.Unlock()
_ = writeJSON(r.conn, msg)
}
// ====================== 消息处理 ======================
// handleExecute 处理工作流执行(由 workerPool 异步调用,不阻塞读循环)
func handleExecute(ctx context.Context, conn *wsCommon.WsConnection, payload interface{}) {
execPayload := new(sessionDto.WebSocketExecWorkflowReq)
if err := gconv.Struct(payload, execPayload); err != nil {
glog.Errorf(ctx, "工作流执行参数解析失败: %v", err)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "执行参数解析失败", Error: err.Error()})
return
}
execCtx, execCancel := context.WithCancel(ctx)
// 替换旧 cancel,设入新 cancel
if oldCancel := getExecCancel(conn); oldCancel != nil {
oldCancel()
}
conn.SetMeta("execCancel", execCancel)
//_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: "开始执行工作流"})
// 异步执行工作流(直接 goroutine,不依赖上游 workerPool 二次排队)
go func() {
// 落库用不带取消的 ctx(保留 request 值),保证前端终止/断连后记录仍能写入
saveCtx := context.WithoutCancel(ctx)
start := time.Now()
var execId int64
var execErr error
recorded := false
defer func() {
if r := recover(); r != nil {
glog.Errorf(execCtx, "workflow panic: %v", r)
execErr = fmt.Errorf("工作流异常: %v", r)
// panic 发生在 executeOrResume 内部(如节点 lambda panic)时,
// 多返回值赋值不会完成,此处 execId 可能为 0,需走兜底按会话+流程查最近"运行中"记录标记失败,
// 避免 exec_workflow 记录卡在 Running
if !recorded {
recordExecutionFailure(saveCtx, conn.SessionId, execPayload.FlowId, execId, execErr)
recorded = true
}
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流异常", Error: fmt.Sprintf("%v", r)})
}
}()
defer conn.SetMeta("execCancel", nil)
// 会话落库:前端 sessionId 对应会话已存在则复用,否则按流程名新建
flowName := defaultSessionName
if flowUser, e := flowDao.FlowUserDao.Get(saveCtx, &flowDto.GetFlowUserReq{Id: execPayload.FlowId}); e == nil && flowUser != nil && flowUser.FlowName != "" {
flowName = flowUser.FlowName
}
if e := ensureSession(saveCtx, conn.SessionId, flowName); e != nil {
glog.Errorf(saveCtx, "工作流会话创建失败: %v", e)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流会话创建失败", Error: fmt.Sprintf("%v", e)})
}
reporter := &wsProgressReporter{conn: conn}
progressCtx := context.WithValue(execCtx, wsProgressCtxKey{}, reporter)
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: fmt.Sprintf("开始执行工作流(共 %d 个节点)", len(execPayload.FlowContent.Nodes))})
execId, execErr = executeOrResume(progressCtx, conn, execPayload)
if !g.IsEmpty(execId) {
glog.Infof(saveCtx, "工作流执行完成,execId: %v", execId)
recordWorkflow(saveCtx, execId, time.Since(start), execErr)
recorded = true
} else if execErr != nil {
// 查询/创建执行记录失败(拿不到 execId)时,兜底把该会话+工作流最近一条"运行中"记录标记为失败
recordExecutionFailure(saveCtx, conn.SessionId, execPayload.FlowId, execId, execErr)
recorded = true
}
if execErr != nil {
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "error", Message: "工作流执行失败", Error: execErr.Error()})
return
}
// 成功:把本次执行保存的结果文件路径(exec_workflow_result)一并推给前端
_ = writeJSON(conn, &wsCommon.WsPushMsg{
Type: "flow_complete",
Message: "工作流执行完成",
Data: map[string]interface{}{
"resultFileUrls": workflowResultFileUrls(saveCtx, execId),
},
})
}()
}
// recordExecutionFailure 记录一次失败状态。有 execId 直接更新该记录;拿不到 execId
// executeOrResume 在创建记录后、返回前 panic,或查询/创建执行记录失败)时,
// 兜底按会话+工作流查最近一条仍处于"运行中"的记录标记为失败,避免前端已报错但记录卡在 Running。
// 最近记录已是成功/失败状态则不处理(可能是上一次执行的结果,不应误改)。
func recordExecutionFailure(ctx context.Context, sessionId string, flowId int64, execId int64, runErr error) {
if !g.IsEmpty(execId) {
recordWorkflow(ctx, execId, 0, runErr)
return
}
lastExec, err := sessionDao.ExecWorkflowDao.GetLatestBySessionAndFlow(ctx, sessionId, flowId)
if err != nil || lastExec == nil {
glog.Errorf(ctx, "兜底标记失败状态失败: sessionId=%s flowId=%d err=%v", sessionId, flowId, err)
return
}
if lastExec.Status == nil || *lastExec.Status != *flow.FlowExecutionStatusRunning.Code() {
return
}
recordWorkflow(ctx, lastExec.Id, 0, runErr)
}
// recordWorkflow 把一次工作流执行写入 exec_workflow/exec_workflow_result:运行记录 + 输出文件结果
func recordWorkflow(ctx context.Context, id int64, duration time.Duration, runErr error) {
// exec_workflow 状态沿用 1-运行中,2-成功,3-失败;前端结果卡片也只识别 1/2/3
// 4 会误显示为"运行中"),故取消同样记为失败,错误信息写"用户已终止执行"
// error_message 存友好提示,error 存原始错误明细
status := flow.FlowExecutionStatusSuccess
var errorMessage, errorDetail string
if runErr != nil {
status = flow.FlowExecutionStatusFailed
if errors.Is(runErr, context.Canceled) {
errorMessage = errWorkflowTerminated
} else {
errorMessage = "工作流执行失败"
errorDetail = runErr.Error()
}
}
_, err := sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{
Id: id,
Status: status.Code(),
Duration: int64(duration.Seconds()),
ErrorMessage: errorMessage,
Error: errorDetail,
})
if err != nil {
glog.Errorf(ctx, "exec_workflow 落库失败: %v", err)
return
}
// 执行成功:重新执行复用了同一条记录,OmitEmpty 的 Update 会跳过空 error_message/error
// 需显式清空,避免上一次失败的报错残留
if runErr == nil {
if _, err := sessionDao.ExecWorkflowDao.ClearError(ctx, id); err != nil {
glog.Errorf(ctx, "exec_workflow 报错信息清空失败: %v", err)
}
}
}
// workflowResultFileUrls 查询指定工作流执行保存的结果文件路径(带文件前缀,与 session/get 返回一致)
func workflowResultFileUrls(ctx context.Context, execId int64) []string {
results, err := sessionDao.ExecWorkflowResultDao.ListByExecId(ctx, execId)
if err != nil {
glog.Errorf(ctx, "查询工作流结果路径失败: %v", err)
return nil
}
prefix, _ := utils.GetFileAddressPrefix(ctx)
urls := make([]string, 0, len(results))
for _, r := range results {
if r.ResultFileUrl != "" {
urls = append(urls, prefix+r.ResultFileUrl)
}
}
return urls
}
// handleCancel 取消工作流执行
func handleCancel(ctx context.Context, conn *wsCommon.WsConnection, _ interface{}) {
if cancel := getExecCancel(conn); cancel != nil {
cancel()
}
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "ack", Message: "已取消工作流执行"})
}
// ====================== 工具函数 ======================
func getExecCancel(conn *wsCommon.WsConnection) context.CancelFunc {
cancel, _ := wsCommon.GetMetaT[context.CancelFunc](conn, "execCancel")
return cancel
}
// writeJSON 业务层写入,委托 WsConnection.WriteJSON(共享 writeMu 写锁)
func writeJSON(conn *wsCommon.WsConnection, data interface{}) error {
return conn.WriteJSON(data)
}
// executeOrResume 决策工作流执行方式:
// - 同会话+同工作流的最近一次执行失败,且本次传递参数与上次一致 → 断点续跑(reExecute,复用原执行记录,从失败断点继续)
// - 其余情况(上次成功 / 上次参数与本次不同 / 无历史记录 / 查询出错)→ 全新执行(execute)
func executeOrResume(ctx context.Context, conn *wsCommon.WsConnection, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
lastExec, err := sessionDao.ExecWorkflowDao.GetLatestBySessionAndFlow(ctx, conn.SessionId, req.FlowId)
if err != nil {
glog.Errorf(ctx, "查询最近工作流执行记录失败: %v", err)
return 0, fmt.Errorf("查询最近工作流执行记录失败: %v", err)
}
if lastExec != nil {
if *lastExec.Status == *flow.FlowExecutionStatusFailed.Code() && flowContentEqual(lastExec.RequestParams, req.FlowContent) {
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "运行开始", Data: map[string]interface{}{
"id": lastExec.Id,
}})
glog.Infof(ctx, "工作流断点续跑,execId: %v", lastExec.Id)
return reExecute(ctx, lastExec.Id)
}
glog.Infof(ctx, "工作流全新执行,lastExec: %v", lastExec)
return execute(ctx, conn, lastExec.Id, lastExec.Status, req)
}
glog.Infof(ctx, "工作流全新执行,无历史记录")
return execute(ctx, conn, 0, nil, req)
}
// flowContentEqual 判断两次工作流参数是否一致(JSON 序列化后字节比对。
// Go struct 按字段声明序序列化、map 键自动排序,同一内容结果确定,可用于参数等价判断)
func flowContentEqual(a, b *entity.FlowInfo) bool {
if a == nil || b == nil {
return a == b
}
ab, err1 := json.Marshal(a)
bb, err2 := json.Marshal(b)
if err1 != nil || err2 != nil {
return false
}
return bytes.Equal(ab, bb)
}
// execute 执行工作流(首次执行;同会话+同工作流最近一次执行为失败状态时复用该记录重新执行,不新建数据)
func execute(ctx context.Context, conn *wsCommon.WsConnection, execId int64, status flow.FlowExecutionStatus, req *sessionDto.WebSocketExecWorkflowReq) (id int64, err error) {
var nodeGroupId = uuid.NewString()
if g.IsEmpty(execId) {
execId, err = sessionDao.ExecWorkflowDao.Insert(ctx, &sessionDto.CreateWorkflowReq{
SessionId: conn.SessionId,
FlowId: req.FlowId,
NodeGroupId: nodeGroupId,
Status: flow.FlowExecutionStatusRunning.Code(),
RequestParams: req.FlowContent,
})
if err != nil || g.IsEmpty(execId) {
glog.Errorf(ctx, "工作流执行记录创建失败: %v", err)
return
}
} else {
// FlowExecutionStatus 是 *int8 别名,Code() 返回包级指针,直接 == 是地址比较恒为 false,
// 需解引用按值比较,否则复用失败记录时不会重置为 Running、也不更新 RequestParams
if status != nil && *status == *flow.FlowExecutionStatusFailed.Code() {
_, err = sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{
Id: execId,
NodeGroupId: nodeGroupId,
Status: flow.FlowExecutionStatusRunning.Code(),
RequestParams: req.FlowContent,
})
if err != nil {
return
}
}
}
_ = writeJSON(conn, &wsCommon.WsPushMsg{Type: "round_start", Message: "运行开始", Data: map[string]interface{}{
"id": execId,
}})
err = BuildExecution(ctx, true, req.FlowId, execId, nodeGroupId, conn.SessionId, req.FlowContent)
if err != nil {
return
}
return execId, nil
}
// reExecute 重新执行工作流
func reExecute(ctx context.Context, execWorkflowId int64) (id int64, err error) {
flowInfo, err := sessionDao.ExecWorkflowDao.GetById(ctx, execWorkflowId)
if err != nil {
return
}
var nodeGroupId = uuid.NewString()
_, err = sessionDao.ExecWorkflowDao.Update(ctx, &sessionDto.UpdateWorkflowReq{
Id: flowInfo.Id,
NodeGroupId: nodeGroupId,
Status: flow.FlowExecutionStatusRunning.Code(),
})
if err != nil {
return
}
err = BuildExecution(ctx, false, flowInfo.FlowId, flowInfo.Id, nodeGroupId, flowInfo.SessionId, flowInfo.RequestParams)
if err != nil {
return
}
return flowInfo.Id, nil
}
func BuildExecution(ctx context.Context, forceNewRun bool, flowId, executionId int64, nodeGroupId string, sessionId string, flowContent *entity.FlowInfo) (err error) {
// =========================================================================
// 构建执行图
// =========================================================================
var nodeList []entity.FlowNode
var runGraph compose.Runnable[any, any]
nodeList, runGraph, err = BuildGraphFromFlowContent(ctx, flowContent)
if err != nil {
return fmt.Errorf("执行工作流失败: %v", err)
}
// =========================================================================
// 构建 ConfigMap
// =========================================================================
nodeInputParams := ExtractFlowNodeFrom(flowContent)
configMap := make(map[string]*entity.FlowNode)
for _, cfg := range nodeInputParams {
configMap[cfg.Id] = cfg
}
for _, i := range nodeList {
configMap[i.Id] = &i
}
// =========================================================================
// 构建全局执行入参
// =========================================================================
execInput := &flowDto.FlowExecutionInput{
NodeGroupId: nodeGroupId,
ExecutionId: executionId,
FlowId: flowId,
ConfigMap: configMap,
SessionId: sessionId,
}
var opts []compose.Option
opts = append(opts, compose.WithCheckPointID(gconv.String(executionId)))
if forceNewRun {
opts = append(opts, compose.WithForceNewRun())
}
_, err = runGraph.Invoke(ctx, execInput, opts...)
if err != nil {
// 图执行被 ctx 取消(WS 断连/用户终止):返回 context.Canceled 语义,让 recordWorkflow
// 记为"用户已终止执行"。此时 Eino 已把断点写入 checkpoint storeDbCheckPointStore 用
// WithoutCancel 落库),重新提交相同参数即可断点续跑。
if ctxErr := ctx.Err(); ctxErr != nil {
return fmt.Errorf("执行工作流失败: %w", ctxErr)
}
info, infoOk := compose.ExtractInterruptInfo(err)
if infoOk {
var errMsg string
var errNodeCount int
for _, item := range info.InterruptContexts {
if item.Info == nil {
continue
}
if g.NewVar(item.Info).IsMap() {
errNodeCount++
valMap := gconv.Map(item.Info)
errMsg = fmt.Sprintf("%v\n%v", errMsg, fmt.Sprintf("节点:%v, 失败原因:%v", valMap["node"], valMap["error"]))
}
}
if !g.IsEmpty(errMsg) {
err = fmt.Errorf("%v个节点,%v", errNodeCount, errMsg)
}
}
return fmt.Errorf("执行工作流失败: %v", err)
}
// 清理断点数据
_ = flowDao.FlowCheckpointDao.Delete(ctx, gconv.String(executionId))
return
}