feat(hitl): 人工审批中断(Eino Phase D)—— 审批节点暂停→批准续跑/拒绝中止
专用「审批」节点方案,全栈打通。 后端: - contract:TaskWaiting/TaskRejected 状态 + ApprovalSubject/ApprovalDecision。 - bus:PublishApproval + WaitApproval(订阅决定主题,带超时);消费者 AckWait 30s→15min(阻塞等人审期间消息未 ack,否则重投成重复任务)。 - dispatcher:ApprovalWaiter 接口 + approvalNode——执行到审批节点发 await 事件 + 置 waiting,阻塞等决定。批准→回 running 放行下游;拒绝/超时→errRejected 哨兵→ 剪下游→rejected,优雅收尾不计熔断。超时安全默认拒绝。 taskExecTimeout 3→10min(审批5 < 执行10 < AckWait15)。 - 网关:POST /tasks/:id/approve(仅 waiting 态受理,幂等)。 桌面端: - Studio 新增「人工审批」节点(nodeCatalog,可填标题/说明)。 - run.ts pendingApproval() 从 exec 流派生待审中断 + waiting 节点状态。 - BottomDrawer ApprovalBar:琥珀审批条(摘要 + 批准/拒绝 + 备注,调 api.approveTask)。 - ExecTrace waiting 状态灯。 验证:后端 curl 实测 waiting→批准→running→done;waiting→拒绝→rejected(下游未跑)。 前端 tsc + vitest 36 过(pendingApproval 4 例 + waiting 状态)。全模块 build+vet+test 全绿。 EINO_ADOPTION Phase D 标记 HITL 完成。 未覆盖:compose 路径(EINO_COMPOSE,默认关)的 approval 节点;桌面端审批条未在 GUI 实点。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,6 +89,34 @@ func (h *Handler) TaskStatus(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": id, "status": status, "detail": detail})
|
||||
}
|
||||
|
||||
// ApproveTask: POST /api/v1/tasks/:id/approve {approved, node?, note?} —— 人工审批决定(HITL)。
|
||||
// 把决定经 NATS 发给 dispatcher,解除审批节点的阻塞(批准放行 / 拒绝中止)。
|
||||
func (h *Handler) ApproveTask(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
var body struct {
|
||||
Approved bool `json:"approved"`
|
||||
Node string `json:"node"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// 仅在任务确为等待审批时受理(幂等:重复/迟到的决定不报错,dispatcher 侧已只取首条)。
|
||||
if status, _ := h.db.GetTaskStatus(c.Request.Context(), id); status != contract.TaskWaiting {
|
||||
c.JSON(http.StatusConflict, gin.H{"error": "任务当前非待审批状态", "status": status})
|
||||
return
|
||||
}
|
||||
if err := h.bus.PublishApproval(&contract.ApprovalDecision{
|
||||
TaskID: id, Node: body.Node, Approved: body.Approved, Note: body.Note,
|
||||
By: userID(c), TS: time.Now().UnixMilli(),
|
||||
}); err != nil {
|
||||
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"task_id": id, "approved": body.Approved})
|
||||
}
|
||||
|
||||
// StreamTask: 以 SSE 把 Token Stream 推给客户端。
|
||||
// 优先从 Redis Stream 读(可回放 + 断点续传,根治连晚/重连丢 token);Redis 降级时回退 live NATS。
|
||||
func (h *Handler) StreamTask(c *gin.Context) {
|
||||
|
||||
@@ -64,6 +64,11 @@ func (b *Bus) SubscribeTaskStatus(onEvent func(*contract.TaskStatusEvent)) (func
|
||||
return b.inner.SubscribeTaskStatus(onEvent)
|
||||
}
|
||||
|
||||
// PublishApproval 把一次人工审批决定发给 dispatcher(解除审批节点阻塞)。
|
||||
func (b *Bus) PublishApproval(dec *contract.ApprovalDecision) error {
|
||||
return b.inner.PublishApproval(dec)
|
||||
}
|
||||
|
||||
// ServeConfig 让网关作为配置控制面,响应某 kind 的配置请求。
|
||||
func (b *Bus) ServeConfig(kind string, provide func() *contract.ModelConfig) (func() error, error) {
|
||||
return b.inner.ServeConfig(kind, provide)
|
||||
|
||||
@@ -50,25 +50,26 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
||||
// —— 受保护:owner 作用域业务,必须携带有效 JWT ——
|
||||
p := api.Group("", middleware.RequireAuth())
|
||||
{
|
||||
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
||||
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||
p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||
p.POST("/tasks", h.SubmitTask) // 解析 DSL 并 Publish 到 NATS(带已验证 uid)
|
||||
p.GET("/tasks/:id", h.TaskStatus) // 任务生命周期状态(UI 轮询 submitted/running/done/failed/timeout/waiting/rejected)
|
||||
p.POST("/tasks/:id/approve", h.ApproveTask) // HITL 人工审批决定(批准/拒绝)
|
||||
p.PUT("/memory", h.SetMemory) // 偏好记忆登记(→ mcp-go memory_upsert)
|
||||
p.GET("/memory", h.ListMemory) // 列出当前用户偏好(记忆面板)
|
||||
p.DELETE("/memory", h.DeleteMemory) // 软删一条偏好(?key=)
|
||||
p.GET("/kb/list", h.KbList) // 当前用户的知识库列表(owner 隔离)
|
||||
p.POST("/kb/create", h.KbCreate) // 新建知识库
|
||||
p.POST("/kb/ingest", h.KbIngest) // 文本入库
|
||||
p.POST("/kb/ingest_file", h.KbIngestFile) // 文件入库
|
||||
p.POST("/kb/search", h.KbSearch) // 检索台
|
||||
p.GET("/kb/vault", h.KbVault) // 文库列表
|
||||
p.GET("/kb/doc", h.KbDoc) // 取单篇文档
|
||||
p.GET("/kb/links", h.KbLinks) // 某库双链
|
||||
p.POST("/kb/note", h.KbSaveNote) // 新建/编辑笔记
|
||||
p.GET("/kb/graph", h.KbGraph) // 知识图谱三元组
|
||||
p.GET("/agents", h.AgentList) // 我的编排列表(owner 隔离)
|
||||
p.POST("/agents", h.AgentSave) // 保存/更新编排
|
||||
p.DELETE("/agents", h.AgentDelete) // 删除编排
|
||||
p.POST("/reports", h.GenerateReport) // 报告生成
|
||||
p.GET("/billing", h.Billing)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user