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:
Blizzard
2026-06-24 13:18:47 +08:00
parent 1d23bdf0a3
commit 16a6c4b1aa
16 changed files with 360 additions and 39 deletions
@@ -37,6 +37,7 @@ function StatusDot({ status }: { status: NodeTrace["status"] }) {
if (status === "running") return <Loader2 className="h-4 w-4 animate-spin text-accent-400" strokeWidth={2.4} />;
if (status === "done") return <CheckCircle2 className="h-4 w-4 text-success" strokeWidth={2.2} />;
if (status === "error") return <XCircle className="h-4 w-4 text-danger" strokeWidth={2.2} />;
if (status === "waiting") return <Loader2 className="h-4 w-4 animate-spin text-amber-400" strokeWidth={2.4} />; // 待审批
return <Circle className="h-4 w-4 text-slate-600" strokeWidth={2} />;
}
+12
View File
@@ -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(
+33 -1
View File
@@ -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("deriveNodesExecEvent 流 → 节点轨迹)", () => {
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("pendingApprovalHITL 待审批中断)", () => {
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();
});
});
+26 -1
View File
@@ -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→runningend→done(带耗时)error→errorinfo→点事件/附注。
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;
@@ -1,7 +1,8 @@
import { useState } from "react";
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import { deriveNodes, type RunState } from "../lib/run";
import { ChevronDown, ChevronUp, Wrench, ShieldCheck, Check, X } from "lucide-react";
import { deriveNodes, pendingApproval, type RunState } from "../lib/run";
import { ExecTrace } from "../components/ExecTrace";
import { approveTask } from "../lib/api";
import { Tabs, Badge, cn, type TabDef } from "../ui";
type Tab = "output" | "trace" | "tools" | "cite" | "eval";
@@ -26,8 +27,11 @@ export function BottomDrawer({ run }: { run: RunState }) {
const statusText =
run.phase === "streaming" ? "流式中…" : run.phase === "done" ? "完成 ✓" : run.phase === "error" ? `${run.error ?? "出错"}` : run.phase === "submitting" ? "提交中…" : "就绪";
const approval = run.taskId ? pendingApproval(run.exec) : null;
return (
<div className="shrink-0 border-t border-line bg-ink-900">
{approval && run.taskId && <ApprovalBar taskId={run.taskId} node={approval.node} title={approval.title} summary={approval.summary} />}
<div className="flex items-center border-b border-line px-2">
<Tabs
tabs={tabs}
@@ -60,6 +64,60 @@ export function BottomDrawer({ run }: { run: RunState }) {
);
}
// ApprovalBarHITL 人工审批中断条。任务停在审批节点时常驻顶部,展示待审摘要 + 批准/拒绝。
// 决定经 approveTask 发回;dispatcher 续跑后 SSE 会推来 end/error 事件,本条随 pendingApproval 归 null 自动消失。
function ApprovalBar({ taskId, node, title, summary }: { taskId: string; node: string; title: string; summary: string }) {
const [note, setNote] = useState("");
const [busy, setBusy] = useState<"approve" | "reject" | null>(null);
const [err, setErr] = useState("");
const decide = async (approved: boolean) => {
setBusy(approved ? "approve" : "reject");
setErr("");
try {
await approveTask(taskId, approved, { node, note });
} catch (e) {
setErr((e as Error).message);
setBusy(null);
}
};
return (
<div className="flex flex-col gap-2 border-b border-amber-500/30 bg-amber-500/10 px-3 py-2">
<div className="flex items-center gap-2 text-[12px] text-amber-200">
<ShieldCheck className="h-4 w-4 text-amber-400" strokeWidth={2.2} />
<span className="font-semibold"></span>
<span className="text-amber-300/80">{title}</span>
<Badge tone="warn"></Badge>
</div>
{summary && <pre className="max-h-20 overflow-auto whitespace-pre-wrap font-mono text-[11px] leading-relaxed text-amber-100/80">{summary}</pre>}
<div className="flex items-center gap-2">
<input
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选,拒绝原因等)"
className="min-w-0 flex-1 rounded border border-line bg-ink-950/60 px-2 py-1 text-[11px] text-slate-200 placeholder:text-slate-600 focus:border-amber-500/50 focus:outline-none"
/>
<button
onClick={() => decide(true)}
disabled={busy !== null}
className="flex items-center gap-1 rounded bg-success/20 px-2.5 py-1 text-[11px] font-medium text-success hover:bg-success/30 disabled:opacity-50"
>
<Check className="h-3.5 w-3.5" /> {busy === "approve" ? "提交中…" : "批准"}
</button>
<button
onClick={() => decide(false)}
disabled={busy !== null}
className="flex items-center gap-1 rounded bg-danger/20 px-2.5 py-1 text-[11px] font-medium text-danger hover:bg-danger/30 disabled:opacity-50"
>
<X className="h-3.5 w-3.5" /> {busy === "reject" ? "提交中…" : "拒绝"}
</button>
</div>
{err && <p className="text-[11px] text-danger">{err}</p>}
</div>
);
}
// ToolCalls:从执行事件里筛出工具调用节点,逐条展示入参 → 产出 + 耗时/状态。
function ToolCalls({ run }: { run: RunState }) {
const tools = deriveNodes(run.exec).filter((n) => n.kind === "tool");
@@ -102,6 +102,18 @@ export const NODE_KINDS: Record<string, NodeKind> = {
fields: [{ key: "condition", label: "条件", type: "text", placeholder: "score > 0.8" }],
defaults: { condition: "" },
},
approval: {
kind: "approval",
label: "人工审批",
accent: "border-l-amber-500",
badge: "bg-amber-100 text-amber-700",
desc: "HITL:暂停等人工批准",
fields: [
{ key: "title", label: "审批标题", type: "text", placeholder: "如:高危操作审批" },
{ key: "prompt", label: "审批说明", type: "textarea", placeholder: "向审批人说明待执行的操作…" },
],
defaults: { title: "人工审批", prompt: "请审批是否继续执行后续步骤" },
},
map: {
kind: "map",
label: "并行 / Map",