feat(engine): NodeState 补 waiting_user/unknown 两态+StepStatus 映射表;node.finished 增检查点负载(A++ 字段定稿);补 sm-crypto 类型声明

This commit is contained in:
yu 2026-11-06 13:19:29 +08:00
parent 4615fcab10
commit a01667bdb3
6 changed files with 169 additions and 3 deletions

View File

@ -12,7 +12,7 @@
1. **宿主底座已大变,V1 的"空壳契约"叙事大部分失效。** V1 说对面 7 个契约包每个只有 1-9 个声明文件、windows-runner 是 0 源文件空壳。实测(2026-11-06):7 个包中 5 个已有实质实现(workflow-engine 25 文件、capability-gateway 321 行、browser-plugin 已接 Playwright、windows-plugin 新增原生观察适配器、runtime-governance 有 readiness 投影),windows-runner 有真实 C# 原生实现(.NET 10 + FlaUI UIA3,observe-only)。**仍无实现的只剩两处:computer-control 的 Runner、windows-plugin 的 WindowsRunner.click/type。**
→ 迁入的本质从 V1 的"拿实现去填契约"变为**"接底座 + 给已有实现让路"**:不重复造 workflow 状态机、不重复造 Playwright 浏览器、不重复造 UIA 观察。
2. **RPA 侧接入前 P0 整改已于 2026-11-06 完成(迁移阶段 1 收口)**:节点尝试级 AbortController(超时即真中止底层操作)、取消映射 cancelled 不走 error route、重试改 `idempotent` 元数据门控、图校验器执行前拒非法流程图、`createEngine(options)` 注入参数(driver/hostBridge/subflowSource/registry)、视觉能力 5 处动态 `import("electron")` 清零改走 HostBridge.screen 注入、check-deps 覆盖动态 import、catalog 脏值修复、rpa-cli 显式注册重写。★ 标记明细见 §2/§7/§8。**接入前仅剩(本仓库代码活)**:①NodeState 补 `waiting_user`/`unknown` 两态(A5,崩溃恢复对齐);②A++ 事件负载字段设计定稿(§7-A5)。已核实销账:包依赖 3 项声明均在 dependencies(2026-11-06 核实)。PowerShell 编码差异验证留待阶段 2 迁包后(bun 本机冒烟已于 2026-11-06 完成:bun 1.4.4,engine 37/37 全绿,exceljs/pdf 行为无差异;对面锁 1.4.3 为 patch 级差异)。
2. **RPA 侧接入前 P0 整改已于 2026-11-06 完成(迁移阶段 1 收口)**:节点尝试级 AbortController(超时即真中止底层操作)、取消映射 cancelled 不走 error route、重试改 `idempotent` 元数据门控、图校验器执行前拒非法流程图、`createEngine(options)` 注入参数(driver/hostBridge/subflowSource/registry)、视觉能力 5 处动态 `import("electron")` 清零改走 HostBridge.screen 注入、check-deps 覆盖动态 import、catalog 脏值修复、rpa-cli 显式注册重写。★ 标记明细见 §2/§7/§8。**接入前本仓库代码活已全部完成(2026-11-06)**:①NodeState 补 `waiting_user`/`unknown` 两态 + `toStepStatus` 全量映射表(与对面 StepStatus 8 态同构,含契约测试);②A++ 事件负载字段定稿——`node.finished` 增 `checkpointId`/`resumable`/`idempotencyKey`(幂等键=runId:nodeId 跨重入稳定,非幂等能力不作为续跑锚点),契约测试覆盖。附带:补 sm-crypto 类型声明(typecheck 归零)。**对接面工作全部在对面仓库**:rpa-adapter、subtree 迁包、垂直切片;本方仅待对面答复 Q1/Q2 与 rpa.browser 备案。PowerShell 编码差异验证留待阶段 2 迁包后(bun 本机冒烟已于 2026-11-06 完成:bun 1.4.4,engine 37/37 全绿,exceljs/pdf 行为无差异;对面锁 1.4.3 为 patch 级差异)。
3. **迁入位置与名称维持 V1 结论**:`code/backend/packages/rpa-core` → `@agent-worker/rpa-core`,backend workspace(bun@1.4.3,workspaces 含 `packages/*`)放进去即生效,backend 全家统一 `@agent-worker/*` 前缀、`workspace:*` 源码消费。
4. **两条硬边界不变且加严**(用户红线):
- 所有带副作用的能力必须经 capability-gateway(授权→租约→幂等→执行→审计),**不得绕行**;

View File

@ -0,0 +1,112 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../types";
import { register } from "./registry";
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "./types";
import { runFlow } from "./runtime";
type EmittedEvent = Omit<RunEvent, "runId">;
type RunFlowOptions = Parameters<typeof runFlow>[1];
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
return {
schemaVersion: 1,
id: "flow_checkpoint",
name: "检查点测试流程",
variables: [],
nodes: partial.nodes,
edges: partial.edges || [],
meta: { createdAt: "2026-01-01T00:00:00Z", updatedAt: "2026-01-01T00:00:00Z", appVersion: "test" }
};
}
function node(id: string, componentId: string): FlowNode {
return { id, componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
}
const fakeComponent = (id: string): CatalogComponent =>
({
id,
name: id,
version: "1.0.0",
tags: [],
platformTag: null,
icon: "",
risk: "low",
fields: [],
output: { label: "", key: "", suggestedName: "", valueTypes: [], fields: [] },
slots: [],
functionCalls: []
}) as unknown as CatalogComponent;
const baseOptions = (emit: (event: EmittedEvent) => void): RunFlowOptions => ({
runId: "run_cp",
evidenceDir: ".",
signal: new AbortController().signal,
emit,
log: () => {},
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
variables: {}
});
register("Cp_Idempotent", async () => "ok", { idempotent: true });
register("Cp_NonIdempotent", async () => "ok", { idempotent: false });
register("Cp_DefaultMeta", async () => "ok");
describe("状态映射(NodeState ↔ 对面 StepStatus)", () => {
it("8 个状态全分支映射,唯一非同名是 idle→pending", () => {
expect(toStepStatus("idle")).toBe("pending");
for (const status of ["running", "waiting_user", "succeeded", "failed", "cancelled", "unknown", "skipped"] as const) {
expect(toStepStatus(status)).toBe(status);
}
});
it("映射表为全量双射(无遗漏键)", () => {
expect(Object.keys(NODE_STATUS_TO_STEP_STATUS).sort()).toEqual(
["cancelled", "failed", "idle", "running", "skipped", "succeeded", "unknown", "waiting_user"]
);
});
});
describe("A++ 检查点事件负载", () => {
it("node.finished 携带 checkpointId/resumable/幂等键,非幂等不带键", async () => {
const events: EmittedEvent[] = [];
const flow = makeFlow({
nodes: [node("first", "Cp_Idempotent"), node("second", "Cp_NonIdempotent"), node("third", "Cp_DefaultMeta")],
edges: [
{ id: "e1", source: "first", target: "second" },
{ id: "e2", source: "second", target: "third" }
]
});
const result = await runFlow(flow, baseOptions((event) => events.push(event)));
expect(result.status).toBe("succeeded");
const finished = events.filter((e) => e.type === "node.finished" && e.status === "succeeded");
expect(finished).toHaveLength(3);
const first = finished.find((e) => e.nodeId === "first")!;
expect(first.checkpointId).toBe("first");
expect(first.resumable).toBe(true);
expect(first.idempotencyKey).toBe("run_cp:first");
const second = finished.find((e) => e.nodeId === "second")!;
expect(second.checkpointId).toBe("second");
expect(second.resumable).toBe(false);
expect(second.idempotencyKey).toBeUndefined();
const third = finished.find((e) => e.nodeId === "third")!;
expect(third.resumable).toBe(true);
expect(third.idempotencyKey).toBe("run_cp:third");
});
it("幂等键由 runId+nodeId 派生,跨重入稳定(重入去重的基石)", async () => {
const flow = makeFlow({ nodes: [node("only", "Cp_Idempotent")] });
const runOnce = async (runId: string): Promise<string | undefined> => {
const list: EmittedEvent[] = [];
await runFlow(flow, { ...baseOptions((event) => list.push(event)), runId });
return list.find((e) => e.type === "node.finished")?.idempotencyKey;
};
expect(await runOnce("run_a")).toBe("run_a:only");
expect(await runOnce("run_a")).toBe("run_a:only");
expect(await runOnce("run_b")).toBe("run_b:only");
});
});

View File

@ -357,13 +357,20 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
state.output = result;
state.finishedAt = new Date().toISOString();
state.durationMs = Date.now() - startMs;
// A++ 检查点协议:成功节点即续跑锚点;checkpointId 取节点 id(跨 attempt 稳定);
// 幂等键同样跨 attempt 稳定,供带 resumeToken 重入时去重;非幂等能力不作为锚点
const checkpointMeta = getCapabilityMeta(node.componentId) || getCapabilityMeta(component.functionName);
const resumable = checkpointMeta?.idempotent !== false;
ctx.emit({
type: "node.finished",
nodeId: node.id,
status: "succeeded",
output: result,
durationMs: state.durationMs,
evidence: state.evidence
evidence: state.evidence,
checkpointId: node.id,
resumable,
...(resumable ? { idempotencyKey: `${ctx.runId}:${node.id}` } : {})
});
return result;
} catch (error) {

View File

@ -139,7 +139,8 @@ export type CapabilityHandler = (
export interface NodeState {
nodeId: string;
componentId: string;
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled";
// waiting_user:节点挂起等待人审(对面 approval 流);unknown:崩溃后结果不可知(对面 workflow-recovery 对齐)
status: "idle" | "running" | "succeeded" | "failed" | "skipped" | "cancelled" | "waiting_user" | "unknown";
startedAt?: string;
finishedAt?: string;
durationMs?: number;
@ -149,6 +150,24 @@ export interface NodeState {
attempt: number;
}
// 与对面 workflow-engine StepStatus(8 态)同构;唯一非同名映射是 idle↔pending
export type WorkflowStepStatus = "pending" | "running" | "waiting_user" | "succeeded" | "failed" | "cancelled" | "unknown" | "skipped";
export const NODE_STATUS_TO_STEP_STATUS: Record<NodeState["status"], WorkflowStepStatus> = {
idle: "pending",
running: "running",
waiting_user: "waiting_user",
succeeded: "succeeded",
failed: "failed",
cancelled: "cancelled",
unknown: "unknown",
skipped: "skipped"
};
export function toStepStatus(status: NodeState["status"]): WorkflowStepStatus {
return NODE_STATUS_TO_STEP_STATUS[status];
}
export interface RunResult {
runId: string;
status: "succeeded" | "failed" | "cancelled";

View File

@ -181,6 +181,10 @@ export interface RunEvent {
durationMs?: number;
evidence?: string[];
attempt?: number;
// A++ 检查点协议负载(ADR-R2 补记):node.finished 成功时由 runtime 填充
checkpointId?: string;
idempotencyKey?: string;
resumable?: boolean;
startedAt?: string;
nodeLabel?: string;
total?: number;

View File

@ -0,0 +1,24 @@
// sm-crypto 无官方类型声明;仅声明本仓库用到的 SM4 解密 API
declare module "sm-crypto" {
export interface Sm4Options {
padding?: "pkcs#7" | "none";
mode?: "cbc";
iv?: string;
}
export const sm4: {
decrypt(
cipherText: string,
key: string,
options?: Sm4Options
): string;
encrypt(plainText: string, key: string, options?: Sm4Options): string;
};
export const sm2: {
doEncrypt(msgString: string, publicKey: string, cipherMode?: 0 | 1): string;
doDecrypt(
cipherText: string,
privateKey: string,
cipherMode?: 0 | 1
): string;
};
}