diff --git a/docs/rpa-core-migration-guide-v2.md b/docs/rpa-core-migration-guide-v2.md index 2efe8e6..384602d 100644 --- a/docs/rpa-core-migration-guide-v2.md +++ b/docs/rpa-core-migration-guide-v2.md @@ -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(授权→租约→幂等→执行→审计),**不得绕行**; diff --git a/packages/rpa-core/src/engine/checkpoint-events.test.ts b/packages/rpa-core/src/engine/checkpoint-events.test.ts new file mode 100644 index 0000000..eedc1a3 --- /dev/null +++ b/packages/rpa-core/src/engine/checkpoint-events.test.ts @@ -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; +type RunFlowOptions = Parameters[1]; + +function makeFlow(partial: Partial & { 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 => { + 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"); + }); +}); diff --git a/packages/rpa-core/src/engine/runtime.ts b/packages/rpa-core/src/engine/runtime.ts index 1ac57bb..4e2ca49 100644 --- a/packages/rpa-core/src/engine/runtime.ts +++ b/packages/rpa-core/src/engine/runtime.ts @@ -357,13 +357,20 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map = { + 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"; diff --git a/packages/rpa-core/src/types.ts b/packages/rpa-core/src/types.ts index afa0d01..a9103aa 100644 --- a/packages/rpa-core/src/types.ts +++ b/packages/rpa-core/src/types.ts @@ -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; diff --git a/packages/rpa-core/src/types/sm-crypto.d.ts b/packages/rpa-core/src/types/sm-crypto.d.ts new file mode 100644 index 0000000..4fd77ff --- /dev/null +++ b/packages/rpa-core/src/types/sm-crypto.d.ts @@ -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; + }; +}