feat(engine): NodeState 补 waiting_user/unknown 两态+StepStatus 映射表;node.finished 增检查点负载(A++ 字段定稿);补 sm-crypto 类型声明
This commit is contained in:
parent
4615fcab10
commit
a01667bdb3
@ -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(授权→租约→幂等→执行→审计),**不得绕行**;
|
||||
|
||||
112
packages/rpa-core/src/engine/checkpoint-events.test.ts
Normal file
112
packages/rpa-core/src/engine/checkpoint-events.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@ -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) {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
24
packages/rpa-core/src/types/sm-crypto.d.ts
vendored
Normal file
24
packages/rpa-core/src/types/sm-crypto.d.ts
vendored
Normal 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;
|
||||
};
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user