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:
@@ -109,6 +109,18 @@ export async function submitTask(dsl: TaskDsl, id: Identity): Promise<string> {
|
||||
return data.task_id;
|
||||
}
|
||||
|
||||
// approveTask: POST /api/v1/tasks/:id/approve —— HITL 人工审批决定(批准放行 / 拒绝中止)。
|
||||
export async function approveTask(taskId: string, approved: boolean, opts?: { node?: string; note?: string }): Promise<void> {
|
||||
const res = guard401(
|
||||
await fetch(`${GATEWAY}/api/v1/tasks/${taskId}/approve`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...bearer() },
|
||||
body: JSON.stringify({ approved, node: opts?.node ?? "", note: opts?.note ?? "" }),
|
||||
}),
|
||||
);
|
||||
if (!res.ok) throw new Error(`approve failed: ${res.status} ${await res.text()}`);
|
||||
}
|
||||
|
||||
// streamTokens: 订阅 SSE /api/v1/tasks/:id/stream,逐 token 回调,done 收尾。
|
||||
// 返回关闭函数。注意 EventSource 无法带请求头,但流按 task_id 寻址,无需身份头。
|
||||
export function streamTokens(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import type { ExecEvent } from "./api";
|
||||
import { deriveNodes } from "./run";
|
||||
import { deriveNodes, pendingApproval } from "./run";
|
||||
|
||||
let seq = 0;
|
||||
function ev(node: string, phase: string, extra: Partial<ExecEvent> = {}): ExecEvent {
|
||||
@@ -48,4 +48,36 @@ describe("deriveNodes(ExecEvent 流 → 节点轨迹)", () => {
|
||||
const [n] = deriveNodes([ev("a", "start", { label: "旧" }), ev("a", "end", { label: "新" })]);
|
||||
expect(n.label).toBe("新");
|
||||
});
|
||||
|
||||
it("await 事件 → 节点状态 waiting", () => {
|
||||
const [n] = deriveNodes([ev("ap", "await", { kind: "approval", label: "审批" })]);
|
||||
expect(n.status).toBe("waiting");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingApproval(HITL 待审批中断)", () => {
|
||||
it("await 未收口 → 返回待审批", () => {
|
||||
const p = pendingApproval([ev("ap", "await", { kind: "approval", label: "高危审批", detail: "摘要" })]);
|
||||
expect(p).toEqual({ node: "ap", title: "高危审批", summary: "摘要" });
|
||||
});
|
||||
|
||||
it("await 后 end(同节点)→ 已决,返回 null", () => {
|
||||
const p = pendingApproval([
|
||||
ev("ap", "await", { kind: "approval" }),
|
||||
ev("ap", "end", { kind: "approval" }),
|
||||
]);
|
||||
expect(p).toBeNull();
|
||||
});
|
||||
|
||||
it("await 后 error(超时/拒绝)→ 已决,返回 null", () => {
|
||||
const p = pendingApproval([
|
||||
ev("ap", "await", { kind: "approval" }),
|
||||
ev("ap", "error", { kind: "approval" }),
|
||||
]);
|
||||
expect(p).toBeNull();
|
||||
});
|
||||
|
||||
it("无审批事件 → null", () => {
|
||||
expect(pendingApproval([ev("a", "start"), ev("a", "end")])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,7 +21,7 @@ export const emptyRun: RunState = { phase: "idle", output: "", events: [], exec:
|
||||
|
||||
// ---- 执行轨迹派生:把扁平 ExecEvent 流归并为按节点聚合的轨迹 ----
|
||||
|
||||
export type NodeStatus = "running" | "done" | "error" | "info";
|
||||
export type NodeStatus = "running" | "done" | "error" | "info" | "waiting";
|
||||
|
||||
export interface NodeTrace {
|
||||
node: string;
|
||||
@@ -34,6 +34,28 @@ export interface NodeTrace {
|
||||
order: number;
|
||||
}
|
||||
|
||||
// PendingApproval 是一个待人工审批的中断(HITL):审批节点已发 await 事件、尚未被 end/error 收口。
|
||||
export interface PendingApproval {
|
||||
node: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
// pendingApproval 从执行事件流里找出当前待审批的中断:取最后一个 kind=approval & phase=await,
|
||||
// 且其后该节点没有 end/error(未被批准/拒绝收口)的事件。无则 null。
|
||||
export function pendingApproval(events: ExecEvent[]): PendingApproval | null {
|
||||
let pending: PendingApproval | null = null;
|
||||
for (const e of events) {
|
||||
if (e.kind !== "approval") continue;
|
||||
if (e.phase === "await") {
|
||||
pending = { node: e.node, title: e.label || "人工审批", summary: e.detail || "" };
|
||||
} else if (e.phase === "end" || e.phase === "error") {
|
||||
if (pending && pending.node === e.node) pending = null; // 已决,收口
|
||||
}
|
||||
}
|
||||
return pending;
|
||||
}
|
||||
|
||||
// deriveNodes 把事件流按 node 归并:start→running,end→done(带耗时),error→error,info→点事件/附注。
|
||||
export function deriveNodes(events: ExecEvent[]): NodeTrace[] {
|
||||
const map = new Map<string, NodeTrace>();
|
||||
@@ -50,6 +72,9 @@ export function deriveNodes(events: ExecEvent[]): NodeTrace[] {
|
||||
case "start":
|
||||
if (n.status !== "done" && n.status !== "error") n.status = "running";
|
||||
break;
|
||||
case "await": // HITL 审批节点暂停,等人工决定
|
||||
n.status = "waiting";
|
||||
break;
|
||||
case "end":
|
||||
n.status = "done";
|
||||
n.ms = e.ms;
|
||||
|
||||
Reference in New Issue
Block a user