比较提交

...
40 次代码提交
作者 SHA1 备注 提交日期
yu 5f5e8bcb0e fix(browser): 审查整改批次2镜像——浏览器生命周期四缺陷(对面 55bc8f1b 同源)
目标:对齐对面 feat 分支 RPA-REV-2 的浏览器侧整改,保持双仓同源
代码逻辑:①killStaleBrowserProcesses 失败记 warning 不再静默;PowerShell -like 元字符转义防扩大匹配;②默认 userDataDir 加随机后缀防并发互踩;③launch 失败路径(/json/list、transport.connect)回收自拉子进程;④ExternalTransport.connect 失败/超时关闭底层 WS 并清理 timer
验证:对面 rpa-core tsc 归零 + engine 32/32;本仓库同代码
风险与回滚:低——失败路径增强,revert 即回
2026-11-09 09:55:59 +08:00
yu 594377ab79 docs: 账本划账——W4 迁包已完成(对面 8085ff47/4e345304,feat 分支 5 笔待审) 2026-11-06 17:49:10 +08:00
yu 41975baf5a docs: 工作清单划账——W2 actuate 合入(对面 b646d4d9)与 W3 双缺陷修复(08e798c/440f7f4f)完成 2026-11-06 17:47:04 +08:00
yu 08e798c087 fix(browser): W3 迁前必修两缺陷——transport 单命令超时 + 定位器统一
目标:ADR-R4 前置修复——①CDP 对端不回包时 send 在有限时间内失败;②同名定位参数在全部网页能力下行为一致
代码逻辑:①ExternalTransport.send 增单命令超时(默认 30s,connect 可传),超时以稳定码 timeout 拒绝并清理 pending(不再依赖引擎级超时兜底+泄漏表项);RuntimeError 原样透传不经 mapCdpError 改写;②selectorOf 三份重复定义(browser/browser-extended/browser-dom)收敛为 src/main/browser/selector.ts 单一事实源,键集合取并集(补 uiWindowSelector);browser-common 的 element_position 位置感知定位语义不同,有意保留
验证:宿主 browser+extended 测试 49/49 全绿;rpa-core 引擎域 32/32 全绿;typecheck 归零
风险与回滚:低——超时默认值对既有快流程无感知;定位器统一仅补齐兜底键,行为为超集
2026-11-06 17:46:06 +08:00
yu 43ae705629 feat(engine): node.finished 全事件补 attempt 审计字段(无人值守审批对账:对面 actionId=runId:stepId:attempt)
目标:审计侧可对账'第几次尝试成功/失败',为无人值守高危动作的审批绑定提供键
代码逻辑:六处 node.finished 发射点统一带 state.attempt(初值1,重试循环内递增);可选字段向后兼容,无事件对象精确断言受影响
验证:引擎域 24/24 全绿 + 新增契约测试(重试3次终态 attempt=3、跳过=1、全事件 ≥1);typecheck 归零
风险与回滚:低——纯新增可选字段,revert 即回
2026-11-06 17:21:45 +08:00
yu 10d125ce9d docs: Q4 源码核查落档(审批上游=workflow-engine 已实现)+ 审批对象三层设计(白名单/流程上架/单次高危动作,弹窗=consent 不走审批单) 2026-11-06 16:44:52 +08:00
yu b4c89fd7d3 docs: PR 审核单草稿(feat/rpa-core-migration),补齐迁入提交单行说明未承载的细节(对面 699df63d 新模板) 2026-11-06 16:34:11 +08:00
yu 751dc04713 fix(rpa-core): compat-audit 报告写系统临时目录,不再污染仓库根(补齐 4f1fbc0 漏提交的修复)
任务 ID:RPA-MIG-0(自仓库收尾)
目标:测试运行不得在仓库根产生 .compat-report.txt,保证工作树干净
代码逻辑:报告路径改为 path.join(os.tmpdir(), ".compat-report.txt"),与对面 feat/rpa-core-migration 已迁入版本一致
验证:对面 48/48 全绿用同一份修复代码;本仓库测试布局与对面一致
风险与回滚:低——仅测试报告输出路径,回滚即恢复原路径
2026-11-06 16:29:08 +08:00
yu 7a553fb270 docs: 工作日志补记(线4迁包完成+对面治理新规) 2026-11-06 16:22:31 +08:00
yu 4f1fbc07dc refactor(rpa-core): 测试文件迁至 test/ 目录(对面 workspace 规范:产品源码树禁含测试);修 platform-groups 测试预载顺序耦合 2026-11-06 16:14:23 +08:00
yu 62652b4fca docs: V2 补记——租约粒度拍板(每节点单独授权+逐节点审批)、版本管理设计(V1)、Q4审批上游/Q5配置面待确认 2026-11-06 15:45:35 +08:00
yu a766902a18 docs: 工作日志补记(双端透明调度拍板) 2026-11-06 15:29:49 +08:00
yu 81ce4bc9b8 feat(engine): RunEvent 增 executor 驻地字段(ADR-R6 补记:双端透明调度设计——三条纪律+确定性路由表+数据不动原则) 2026-11-06 15:29:34 +08:00
yu 3a0ff42ac6 docs: 晨间助手截图时机搁置(提案3保留备查) 2026-11-06 15:01:21 +08:00
yu fb6e8ca0bf docs: 后续工作完整清单(W1-W14)+ Q1/Q2/截图时机/rpa.browser 四项设计提案定稿 2026-11-06 14:59:54 +08:00
yu fec0028bd5 docs: 工作日志补记(远端核查+port 保护) 2026-11-06 13:53:55 +08:00
yu 0f498afff2 fix(catalog): port 默认值 9222 写入 overrides 防重生成回退(远端合并发现的回填陷阱) 2026-11-06 13:53:55 +08:00
yu e62a408a0d docs: 工作日志补记(SDK 安装与编译验证) 2026-11-06 13:47:40 +08:00
yu f0defc2199 fix(contrib): 编译验证修正(Point internal / using Diagnostics);补丁包标定 dotnet build 0w0e + 协议冒烟通过 2026-11-06 13:47:27 +08:00
yu 107fbe0cee Merge branch 'main' of http://43.140.215.244:7040/wangyukun/PRA-only-demo 2026-11-06 13:45:00 +08:00
yu 07a76b42b7 docs: 工作日志补记(对面门禁核查+C# actuate 原型) 2026-11-06 13:39:24 +08:00
yu 65343da311 feat(contrib): C# actuate 原型补丁包(协议扩展+NativeInput 五招兜底移植+ActuateService+移植对照表+合入指引);V2 R3 行标定原型产出 2026-11-06 13:39:08 +08:00
yu 33b85c081e Merge remote main; re-apply remote port default 9222 to regenerated catalog 2026-11-06 13:38:49 +08:00
yu 407aa385d2 docs: V2 补记对面事实一致性门禁合规要求(补丁须同提交更新事实文件+文档区块) 2026-11-06 13:32:34 +08:00
yu a01667bdb3 feat(engine): NodeState 补 waiting_user/unknown 两态+StepStatus 映射表;node.finished 增检查点负载(A++ 字段定稿);补 sm-crypto 类型声明 2026-11-06 13:19:29 +08:00
yu 1b952e3bbd Merge remote main; re-apply remote port default 9222 to regenerated catalog 2026-11-06 13:10:10 +08:00
yu 4615fcab10 docs: 账面盘点——包依赖3项核实销账,剩余 NodeState 两态+A++ 字段设计 2026-11-06 13:08:58 +08:00
yu 93915dfe8a catalog: 远程连接默认端口 61619 改为 9222 2026-11-06 13:06:54 +08:00
yu 2bcda3a558 fix(catalog): build-catalog 白名单归一化 platformTag,清洗上游 3 条数字脏值(重派生 win32) 2026-11-06 13:06:54 +08:00
yu 4b5c73b752 feat(word): Word_Save/Word_SaveAs 真实现(COM 落盘),替换路径回显占位 2026-11-06 11:52:56 +08:00
yu b18d5b1f0b docs: bun 1.4.4 本机冒烟通过(engine 37/37, exceljs/pdf 无差异),R6 部分缓解 2026-11-06 11:40:36 +08:00
yu d99be8f4ab docs: ADR-R2 补记 A++ 检查点协议(两侧改动清单+落地节奏);标定阶段1已完成项 2026-11-06 11:36:10 +08:00
yu 1f804971f4 feat(engine): 补交 engine.execute 实现(A1 漏加文件) 2026-11-06 11:09:46 +08:00
yu 7fa1c4038c docs: 补记工作日志(迁移阶段1四项) 2026-11-06 11:09:14 +08:00
yu 9bb5a44b95 chore: rpa-cli 显式注册+engine.execute;V2 现状表同步阶段1修复状态 2026-11-06 11:09:12 +08:00
yu 5e8fc9686f fix(catalog): 修复重生成遗留的三类数据回归(迁移阶段1-T4)
- 昨日重生成用的对面旧版源数据缺 select 选项:从重生成前快照收割 782 个
  select/radio 字段的 options 进 overrides(含默认值落在选项内的校验)
- 移除实现不读的假字段:Excel_Cs_InsertColumnV3/InsertRowV3 的 direction
- 默认值修复:Time 系列 offset_unit_num 改数字 0(实现按 num 读取)、
  Mail_DoGetMailMessage_v2:attachment 恢复旧选项、Window_SizeWindow_v1:window_size
  恢复 text widget、Mobile_TouchSlideV1 无实现清空默认值
- app-system-tests 期望更新:12 号锁屏流程因假必填清理不再阻塞(有意变更)
- catalog-properties 13 项 + app-system 全过
2026-11-06 10:53:56 +08:00
yu 1a20dd72d1 feat: 视觉能力去 Electron 化,经 HostBridge.screen 注入(迁移阶段1-A9)
- HostBridge 新增可选 screen 服务(listDisplays/primaryDisplay/dipToScreenPoint/
  displayMatching/captureDisplay),vision 3 处、cv-vision 2 处动态 import(electron)
  全部改为 requireScreenService();未注入时抛稳定的 host_unavailable
- demo 宿主 src/main/index.ts 用 desktopCapturer/screen 实现 screen 服务
- check-deps.mjs 升级:新增动态 import()/require() 扫描(旧正则实测 0 命中的盲区),
  合成用例验证命中,升级后真实源码 0 违规
- 包依赖闭包:补声明 @cantoo/pdf-lib、@napi-rs/canvas、pdfjs-dist
- cv-vision 测试从 vi.mock(electron) 改为宿主桥注入,11/11 通过
2026-11-06 10:53:42 +08:00
yu 1da00827ac feat(engine): createEngine 注入参数 + 消除模块级 driver 捕获(迁移阶段1-A1)
- createEngine(options) 支持 driver 注入,新增 engine.setDriver;
  缺省保持按平台自动选择的行为
- desktop/common-final/system-extended/window-extended 四文件模块加载时
  捕获 defaultEngine.driver 改为惰性 getDriver():非 win32 import 不再抛错
  (此前 darwin 上 import 即炸),调用时才触发 platform_unsupported
- engine-driver.test 补注入/隔离/后置替换 4 例
2026-11-06 10:20:29 +08:00
yu 9b76dc6987 feat(engine): 取消贯通/超时真中止/重试门控/图校验(迁移阶段1-A3)
- 节点尝试级 AbortController:run 取消与节点超时汇聚到同一信号,
  handler 收到的 ctx.signal 即尝试信号,其内部 sleep/HTTP/子进程可被真正中止
- 超时中止底层后 handler 先抛 cancelled 的竞态归一为 timeout
- executeNode 取消映射 NodeState=cancelled:不适用 skipOnError、不进异常分支
- 重试门控:CapabilityMeta.idempotent===false 时无视 retryCount(防重复副作用)
- 新增 validateFlowGraph:重复ID/悬空边/自环/顶层环/孤儿节点/不可达节点,
  阻断性错误在 runFlow 内统一收口为 validation_error
- 新增 11 项测试;既有 error-route/integration 回归通过
2026-11-06 10:13:38 +08:00
yu d972a18bda docs: V2 拍板 ADR-R2(方案A+两补丁),6 项 ADR 全部闭环 2026-11-06 10:01:51 +08:00
共修改 86 个文件,包含 16902 行新增和 511 行删除

没有与搜索条件相匹配的文件

+13
查看文件
@@ -286,3 +286,16 @@
- 用户确认两项关键决策:①桌面点击逻辑改写进对面 C# 原生进程(PowerShell 通道仅限 demo);②按能力域拆分部署(纯 Node 域→backend plugin-worker,桌面/视觉/弹窗域→用户电脑 Electron 宿主)
- V2 文档已更新:§0 结论、§10 拆"已拍板/待评审"、A6 改哪侧、阶段 0 范围、风险 R1/R2 降级为已缓解
- 剩余 4 ADR(R1/R2/R4/R5)+ 3 Q 留待与对面对评审
## ADR-R2 拍板(11-06 10:00)
- 方案 A + 两补丁:RPA 流程=单能力 rpa.flow.execute;节点事件走 event-bus 旁路;人审/高危节点升级对面审批。B 触发条件留档(仅当产品需逐步控制)。不用 B 的三层理由(双账本/控制流节点无副作用/审批重试粒度)已写入 §10。
- 至此 6 项 ADR 全部拍板,仅剩 Q1/Q2 待对面确认。6-阶段计划可开工,阶段 1(RPA 侧收口)不依赖对面。
## 迁移阶段1开工(11-06 10:03-11:10)
- A3(9b76dc6):节点尝试级 AbortController(run取消+节点超时汇聚),超时真中止底层;取消映射 NodeState=cancelled(不适用 skipOnError/不进异常分支);CapabilityMeta.idempotent 门控重试;graph-validator.ts(重复ID/悬空边/自环/环/孤儿/不可达)runFlow 前置校验;11 新测试
- A1(1da0082):createEngine({driver})+setDriver;4 个能力文件模块级 driver 捕获改惰性 getDriver()(非 win32 import 不再炸)
- A9(1a20dd7):HostBridge.screen 服务(listDisplays/primaryDisplay/dipToScreenPoint/displayMatching/captureDisplay),vision/cv-vision 5 处动态 electron import 清零;demo 宿主实现 screen 服务;check-deps 升级覆盖动态 import(实测合成命中1/真实0);包依赖补 3 项
- T4(5e8fc96):修复昨日 catalog 重生成遗留回归——对面旧版源数据缺 select 选项,从旧快照收割 782 个 options 进 overrides;移除 InsertColumn/RowV3 假字段 direction;Time 系列 offset_unit_num 默认改 0(实现按 num 读);Mail attachment 恢复选项;app-system-tests 12 号流程期望更新(假必填清理后不再阻塞)
- rpa-cli 重写:显式 5 域注册 + engine.execute 语义真实入口
- 全量回归:390 过;唯一失败是 mouse-moveto-smoke 真桌面测试被用户鼠标干扰的环境抖动(两次全量失败用例不同=铁证),要求独占桌面
- 教训: harvest overrides 时只对齐了 required/label,漏了 options——重生成前必须全字段 diff 而不是抽查
+88
查看文件
@@ -0,0 +1,88 @@
# 2026-11-06 工作日志
## ADR-R2 补记:A++ 检查点协议(拍板)
- 用户确认在方案 A 基础上采用 A++:节点事件流从"透明度日志"升级为检查点协议(nodeId/status/checkpointId/idempotencyKey/resumable),崩溃后 rpa.flow.execute 带 resumeToken 重入断点续跑,权威账本仍为单 step。不动 A、不启用 B。
- 业界对照论据(可与对面沟通用):UiPath Orchestrator≈A(Job 黑盒+日志旁路);Camunda/Zeebe External Task 的单引擎假设不适用于双引擎集成(B 被否的根因);AWS Step Functions callback≈补丁 2;Temporal 理论最干净但需重写,不采纳。
- 两侧改动清单落档 §7-A5:RPA 侧=事件负载字段+resumeToken 重入+幂等键贯通(复用 A3 idempotent 门控);对面侧=step 允许带 token 重入不改租约语义+event-bus 识别检查点+workflow-recovery 回传 token。
- 落地节奏:阶段 2 只定字段设计(近零成本),阶段 3 垂直切片最小验证,阶段 4/5 强制。
## 文档已完成项标定
- §0 核心结论第 2 条重写:P0 整改已完成(原表述"大部分未解决"已过时);仅剩 bun 冒烟+包依赖 3 项核实。
- §7 A1/A3/A9 方案行标 ★ 已实现(2026-11-06 阶段 1)。
- §8 阶段 1 标 ✅ 已完成(提交 9b76dc6/1da0082/1a20dd7/5e8fc96);阶段 2 加"A++ 字段设计定稿";阶段 3 加"检查点续跑最小验证"。
- §9 R8 缓解列补 A++ 统一崩溃恢复语义。
- 提交:d99be8f。
## bun 冒烟(2026-11-06 下午补)
- bun 已装于 C:\Users\Administrator\.bun\bin\bun.exe(版本 1.4.4,PATH 未刷新需全路径调用;对面锁 1.4.3 为 patch 级差异)。
- 纯逻辑冒烟通过:bun test packages/rpa-core/src/engine/ + excel-common/excel-store/pdf-high = 37/37 全绿,exceljs/pdf 行为与 Node 无差异。
- 注意:engine 测试必须从仓库根目录跑(catalog.json 按 cwd 解析,包内跑必挂 2 个),与 npm 侧既有 cwd 问题一致。
- PowerShell 编码差异项留待阶段 2 迁包后验证(R6 已标"部分缓解")。提交:b18d5b1。
## Word_Save/Word_SaveAs 真实现(2026-11-06)
- word.ts:44 占位(只回显路径)替换为真实现:Word_Save=存在性校验+打开即存;Word_SaveAs=打开原文档→SaveAs2(目标)→返回新路径句柄。
- 参数与 catalog 对齐:objWord(必填)+fileAddr(目录,空则沿用原目录,相对时基于句柄当前目录解析)+filename(空则沿用原名,无扩展名补 .docx)。
- 新增 COM 用例(word-extended.test.ts):另存新目录+改名/空名沿用/相对目录/Save 拒绝不存在句柄,4/4 全绿。注意:相对目录基于句柄当前目录(与 Excel 一致),首版测试预期写错已修正。
- 提交:4b5c73b。
## catalog 3 个数字脏值清洗(2026-11-06)
- 脏值:Basic_BusinessOrderCount / Dialog_GetConfigV5 / Dialog_GetConfigV7 的 platformTag=数字 1,源头是上游 shizai 数据(源数据分布 null=994 / 1=3)。
- 修复:build-catalog.mjs 白名单归一化(非 win32/darwin/linux 字符串一律重派生,A8 方案落地);三条均为包内真实现,重派生为 win32。
- 验证:重生成后 win32=690/null=326/总数 1016 不变;全字段 diff 仅 3 条仅 platformTag 变化(overrides 无损);platform-groups+register-all 测试 6/6。
- 注意:Git Bash 的 /tmp 与 node 路径映射不一致(node 把 /tmp 解析为 <盘>:\tmp),快照 diff 用 cygpath 取真实路径。
- 提交:2bcda3a。
## 两个接入前补丁完成(2026-11-06,a01667b)
- 对面源码在 D:\code\Agent-Employee(backend/packages/...),StepStatus 8 态(workflow-engine/src/index.ts:37),StepRun 带 idempotencyKey(来自租约);对面明确"重启后中断步骤走恢复流程不得续跑"——与 A++ resumeToken 重入设计吻合。
- 补丁 1:NodeState 补 waiting_user/unknown 两态;导出 toStepStatus/NODE_STATUS_TO_STEP_STATUS 全量映射(唯一非同名 idle↔pending)。
- 补丁 2:RunEvent 增 checkpointId/resumable/idempotencyKey;runtime 在 node.finished 成功时填充(checkpointId=node.id,幂等键=runId:nodeId 跨重入稳定,meta.idempotent===false 不作为锚点)。契约测试 checkpoint-events.test.ts 4 用例。
- 附带:sm-crypto.d.ts 类型声明补齐(typecheck 归零,此前 basic-encryption 一直报 TS7016)。
- engine 全量 31→33 用例全绿;账面:本仓库代码活清零,剩余全为对面配合项。
## 遗留待办
- ocr-smoke 清理:用户拍板"算了"(2026-11-06),维持现状——素材缺失时整 describe 静默跳过;D:/office-test2 硬编码保留。后续若迁移对面仓库需重提。
- bun@1.4.3 安装+冒烟(阶段 2 前置,上次因用户在用电脑未装)
- Word_SaveAs 真实现、晨间助手截图时机(待对面确认)、ocr-smoke 清理、catalog 3 个数字脏值清洗
- 跑全量测试前先询问用户(占桌面)
## 对面更新核查(13:30)
- windows-runner 自 10-22 未变;workflow-engine 代码未变——既有可行性结论全部有效
- 对面新增事实一致性门禁(document-facts.mjs + 当前事实.json):代码/事实/文档区块须同提交,CI 拦截
- 对面 README Windows 行明确下一步"click/type、视觉/OCR 或随本轮交付"——R3 路线吻合
- V2 补记门禁合规要求(407aa38)
## C# actuate 原型补丁包(65343da)
- 产出 contrib/windows-runner-actuate/:PORTING-MAP.md(逐原语对照+3 语义差异+错误码映射)、ActuateProtocol.cs、NativeInput.cs(五招兜底逐行移植)、ActuateService.cs(安全门同构+前后证据)、PATCH-NOTES.md(Program.cs 最小 diff+门禁合规清单)
- 关键设计:NativeWindows 只读边界不动(输入 P/Invoke 独立);mouse_event→SendInput;模糊→精确匹配由 TS 宿主 observe 弥合
- 未编译:本机无 dotnet SDK;编译验证待装 .NET 10 SDK 或对面侧首次合入时
## .NET SDK 安装 + 编译验证(13:40)
- winget 装 .NET SDK 10.0.403(对面 net10.0-windows 匹配)
- 编译方式:对面 native 拷贝到 .workbuddy/tmp/actuate-build + 三件套 + PATCH-NOTES §2 最小 diff,不碰对面仓库
- dotnet build 0 警告 0 错误(TreatWarningsAsErrors 开启)
- 编译修正 2 处:NativeInput.Point private→internal(CS0050)、ActuateService 补 using System.Diagnostics(CS0103)→ 提交 f0defc2
- 零桌面交互协议冒烟 5 例:policy_invalid/desktop_session_mismatch/observe 无回归/requestId 回显 全过;越权窗口/非法动作先报 window_not_found(窗口选择先于动作校验,与 observe 顺序一致,预期行为)
- 真实窗口用例(click/type/activate)留对面 test/fixture
## 远端核查 + port 9222 保护(13:50)
- 远端无强推、无被删文件回填:33b85c0/1b952e3 是"yu"的合并提交,另一侧只有 93915df(catalog 远程连接端口 61619→9222)
- 发现陷阱:9222 只改了生成产物,未进 overrides,重生成会打回 61619(已发生过一次,故 merge 标题有 re-apply)
- 修复:WebBrowser_Create/V2/V3 的 port 字段(defaultValue+description)写入 catalog-overrides.json,重生成验证 1016 条零差异
- 本地领先远端多笔,待推送
## 拍板:晨间助手截图时机搁置(15:00)
- 用户拍板"先不管",提案 3(挂证据节奏方案)保留在 next-work-plan.md 供未来重启,不再作为工作项/对面对确认项
## 拍板:双端透明调度(15:25,81ce4bc)
- 用户拍板:同一能力前端(用户电脑宿主)与后端(plugin-worker)两驻地并存,调度对用户无感知,SaaS/本地部署通用
- 防乱设计三纪律写入 V2 §10 R6 补记:①一个脑决策(宿主 Resolver,registry handler 绑定本地实现或远端代理,流程/能力代码禁写驻地 if-else);②一份契约两处实现(同一套契约测试,代理自动生成防漂移);③数据不动原则(代码就数据;搬数据必须显式节点,路由器禁隐式搬运本地文件——隐私红线)
- 确定性路由表 6 条:桌面→仅本地;数据在哪就在哪算;双副本默认本地可 prefer backend;宿主离线可后端兜底;均不可用报 location_unavailable
- 代码落点:RunEvent 增 executor: "local"|"backend" 字段(用户不展示、审计必须可见),runtime 成功事件恒发 "local",契约测试覆盖;远端代理落地后由代理覆盖该字段
## RPA 线4 迁包完成(16:20,对面 feat/rpa-core-migration 已推远端)
- 对面 main 新增三笔治理提交:第五条线主控审查与合并流程、多线并行规则、PR/commit 模板(功能线含"RPA 插件");规则=不直接合 main、分支专人审核
- 迁包两笔提交:8085ff47(包+catalog/overrides/duplicates 资源+bun.lock+事实刷新)、4e345304(测试移 test/ 目录+compat-audit 报告改写 tmpdir)
- 关键发现与处置:①bun 1.4.4 打不动对面 patch(photon-node hunk#10),必须 bun 1.4.3(npm i -g bun@1.4.3)→ 649 包安装成功;②对面 workspace 规范禁测试文件在 src/ → 39 个测试移 test/,脚本重写导入(含副作用导入两轮修正);③platform-groups 测试预载顺序耦合修复(退化分支);④compat-audit 报告改写 os.tmpdir 防脏树
- 验证:对面环境 bun test 48/48 全绿(bun 1.4.3);check-workspace rpa-core 项清零;document-facts PASS(顺手把 main 既有漂移一并刷新)
- 遗留:opencode fork 快照漂移=对面 main 旧账(apps/desktop 大量 changed,需他们跑 check-opencode-fork --update);rpa-adapter 未动(依赖 Q4 每节点授权拍板已定、Q5 审批上游待确认)
- 本仓库同步提交 4f1fbc0(测试迁移+两处测试修复)
@@ -0,0 +1,81 @@
# PATCH-NOTES:合入对面 windows-runner 的集成指引
本目录是 **本地原型补丁包**,不改动对面仓库。合入时按以下步骤执行。
## 1. 文件落点
| 本目录文件 | 落到对面仓库 | 说明 |
|---|---|---|
| `native/ActuateProtocol.cs` | `code/backend/services/windows-runner/native/` | 新增类型,不动现有文件 |
| `native/ActuateService.cs` | 同上 | 新增类型,不动现有文件 |
| `native/NativeInput.cs` | 同上 | 新增类型;**NativeWindows.cs 保持只读边界原样不动**(对面注释明言该层无输入接口,编译边界得以保全) |
csproj 无需改动:`UseWindowsForms` 已启用(SendKeys/Clipboard/Screen 可用),FlaUI 引用与本补丁无关。
## 2. Program.cs 分发改造(最小 diff)
现有 Main 只认 `observe`。改造为按 Operation 分发:
```csharp
// 原:if (request.ProtocolVersion != 1 || request.Operation != "observe" || ...)
// 改为先解公共头再分发(保持 64KiB/UTF-8/UUID 校验全部不变):
if (request.ProtocolVersion != 1 || !Guid.TryParse(requestId, out _))
throw new RunnerException("request_invalid");
switch (request.Operation)
{
case "observe": { /* 原 observe 路径逐字保留 */ }
case "actuate":
{
var actuate = JsonSerializer.Deserialize<ActuateRequest>(line, JsonOptions)
?? throw new RunnerException("request_invalid");
var result = ActuateService.Actuate(actuate, evidenceRoot);
Write(new RunnerResponse(1, requestId, true, null, null, result));
return 0;
}
default: throw new RunnerException("request_invalid");
}
```
RunnerResponse 需加一个可选字段(observe 的线上形状不变,`WhenWritingNull` 已保证):
```csharp
internal sealed record RunnerResponse(int ProtocolVersion, string RequestId, bool Ok,
ComputerObservation? Data, string? ErrorCode, ActuateResult? ActuateData = null);
```
## 3. TS 宿主侧(native-observer.ts → actuate 适配器,属后续工作)
- 请求侧:actuate 前先用 observe 解析**精确**窗口标题与进程名(RPA 侧"标题包含匹配"的语义在宿主层弥合,见 PORTING-MAP 第二节);
- 响应侧:对 ActuateData.Evidence 逐项复核(realpath 归属证据根目录、SHA-256、PNG 魔数——复用 native-observer.ts 现成校验);
- 高影响审批:`rpa.windows` manifest 已是 `type=highImpact + idempotencyRequired`,capability-gateway 的精确 actionId 放行与租约语义直接适用,**无需改 gateway**;
- 超时:宿主杀进程超时须覆盖 mouse_move_smooth 最坏路径(≈4s)+ 两次截图(≈2s),建议 ≥15s。
## 4. 验证计划(用对面现成夹具)
- `test/fixture/`(AgentWorker.WindowsFixture):仿照 `native-observer.integration.test.ts` 增加 actuate 集成测试:
1. `mouse_move` → GetCursorPos 断言落点;
2. `mouse_click`(无桌面断言,仅断言 ok + 证据 SHA 与文件存在);
3. `keyboard_send`(chars 模式)→ UIA 读回文本;
4. `window_activate` → GetForegroundWindow 断言;
5. 反向用例:越权窗口 → `window_not_allowed`;Session 不匹配 → `desktop_session_mismatch`;非法 Kind → `action_invalid`。
- 兼容性矩阵(微信/Office/UWP)按对面既有测试纪律单独排期,不在本补丁范围。
## 5. 门禁合规清单(对面 2026-11-06 新规,硬性)
合入提交必须**同一提交内**包含:
1. `项目资料/开发方案/当前事实.json`:factRevision 递增,trackedInputs 如受影响同步更新;
2. 相关 README 的机器生成区块(`node code/tooling/document-facts.mjs --write` 重新生成);
3. `code/backend/README.md` Windows 行状态更新("click/type" 从"或随本轮交付"改为已实现口径);
4. 提交前本地跑:`node code/tooling/document-facts.mjs --check` && `node code/tooling/ci-gate.mjs --mode fast` && `git diff --check`。
## 6. 验证状态与已知限制
- **已编译通过 + 协议冒烟通过(2026-11-06,.NET SDK 10.0.403)**:
- `dotnet build`(对面 csproj 原样 + 本补丁三文件 + §2 最小 diff):**0 警告 0 错误**(TreatWarningsAsErrors 开启,直接达到对面合入标准);
- 编译修正两处:`NativeInput.Point` 由 private 改 internal(CS0050 可访问性)、`ActuateService.cs` 补 `using System.Diagnostics;`(CS0103);
- 零桌面交互协议冒烟 5 例:`policy_invalid` ✅ / `desktop_session_mismatch` ✅ / observe 老路径无回归 ✅ / requestId 逐字回显 ✅;另 2 例(越权窗口/非法动作)因**窗口选择先于动作校验**的既有顺序先报 `window_not_found`——顺序与对面 observe 一致,属预期行为,用例已按此修正理解;
- 需要真实窗口的用例(click/type/window_activate/action_invalid 路径)留待对面 `test/fixture` 集成测试(第 4 节),避免占用用户桌面。
- `Clipboard.SetDataObject` 依赖 STA:对面 Main 已是 `[STAThread]`,满足;
- mouse_event → SendInput 为现代等价替换(标志位映射见 PORTING-MAP 第四节);如对面评审倾向零差异,可回退为 mouse_event P/Invoke,一行替换;
- 平滑移动公式中的伪随机抖动与 JS `%` 保号语义一致(C# `%` 对 double 同为保号),无漂移。
@@ -0,0 +1,49 @@
# PowerShell → C# 移植对照表(RPA 桌面驱动 → windows-runner actuate)
来源:`packages/rpa-core/src/driver/windows/windows-driver.ts`(269 行,内嵌 PowerShell)
目标:对面 `code/backend/services/windows-runner/native/`(原型见本目录 `native/`)
## 一、原语级映射
| RPA 侧 PowerShell 原语 | 用途(驱动行号) | C# 等价物(原型落点) | 语义保真要点 |
|---|---|---|---|
| `SetCursorPos(x,y)` | moveTo / click / drag / moveSmooth(L142,150,158,173) | `NativeInput.SetCursorPos` | 物理像素;进程入口声明 Per-Monitor V2 DPI(对应 PS 每脚本的 `SetProcessDpiAwarenessContext(-4)`,一次声明进程级生效) |
| `mouse_event(flags,0,0,data,0)` down/up | click / drag(L150,158) | `SendInput`(MOUSEEVENTF_LEFTDOWN=0x0002 / LEFTUP=0x0004 / RIGHTDOWN=0x0008 / RIGHTUP=0x0010) | SendInput 是 mouse_event 的现代等价;标志位一一对应。双击=repeats 2,settleMs 逐次保留 |
| `mouse_event(0x0800,...,amount)` | scroll(L161) | `SendInput` + MOUSEEVENTF_WHEEL,mouseData=有符号 delta | 负值向下滚,`unchecked((uint)amount)` 保持位型一致 |
| `GetCursorPos` + JS `mousePath` 插值 | moveSmooth(L163-174) | `ActuateService.SmoothMovePath`(公式逐行移植) | 缓入缓出二次曲线 + 垂直抖动 `sin(i*12.9898)*43758.5453 % 1`;stepCount=clamp(distance/12, 6, 50);delay=clamp(1500/speed, 4, 80)ms。JS 与 C# 的 `%` 对负数同为保号语义,公式无漂移 |
| `Set-Clipboard` + `SendWait('^v')` | keyboard clipboard 模式(L183) | `Clipboard.SetDataObject(text, true)` + `SendKeys.SendWait("^v")` | 120ms 间隙保留;STA 已由对面 `[STAThread]` 保证 |
| `SendWait($c)` 逐字符循环 | keyboard manual 模式(L188) | 逐字符 `SendKeys.SendWait(Escape(ch))` + `Thread.Sleep(intervalMs)` | intervalMs 逐字符保留 |
| `SendWait(escaped)` 整段 | keyboard 默认模式(L192) | `SendKeys.SendWait(escaped)` | `sendKeysEscape` 字符集 `+^%~(){}[]` → `{+}` 等花括号转义,逐字符同规则移植(`ActuateService.EscapeSendKeys`) |
| `activateWindowScript` **抢前台五招兜底** | focus(L73-105,键盘与激活共用) | `NativeInput.FocusChain`(逐行移植) | 顺序与睡眠严格保留:①IsIconic→SW_RESTORE(9)+150ms ②SwitchToThisWindow+150ms ③ALT(0x12 down/up)+SetForegroundWindow+200ms ④AttachThreadInput 对线程+SetForegroundWindow+200ms ⑤SW_MINIMIZE(6)+150ms→SW_RESTORE(9)+300ms ⑥仍失败→稳定错误码 `focus_failed`(PS 版走 `RPA_ERROR::` stdout 通道) |
| `Get-Process | MainWindowTitle -like *title*` | 窗口查找(L60-68 等) | `Win32 EnumWindows` + 精确标题 + PID 回查进程名 + Session 校验(复用对面 observe 的 SelectWindow 模式) | **唯一语义变更**,见下节 1 |
| `GetWindowRect` + JSON 输出 | windowObject / foregroundPosition(L66,213) | `NativeWindows.GetWindowRect`(对面已有,直接复用) | RECT right/bottom → width/height 换算保持 |
| `ShowWindow(h, 3/6/9/cmd)` | activate/maximize / setState / 五招内部(L203,224) | `NativeInput.ShowWindow` | 命令值原样透传 |
| `SetWindowPos(h,0,x,y,w,h,4)` | setSize / setPos(L227,231) | `NativeInput.SetWindowPos` | flags=4(SWP_NOZORDER)原样保留;尺寸>0 校验保留(L230) |
| `Process.CloseMainWindow()` | close(L221) | `PostMessage(h, WM_CLOSE=0x0010)` | 等价语义(礼貌关闭,不杀进程) |
| `System.Windows.Forms.Screen.PrimaryScreen.Bounds` | screen.size(L246) | 不移植——observe op 的 CoordinateSpace 已携带窗口矩形+DPI | 避免重复采集 |
## 二、必须知道的三处语义差异(宿主适配层负责弥合)
1. **窗口匹配从模糊变精确**。PS 版 `Get-Process -like *title*` 是包含匹配;对面安全政策要求 WindowTitle 与 AllowedWindows **精确相等**(模糊匹配会把同名网页/提示框误当目标)。弥合方式:TS 宿主适配器先用 observe 解析出精确标题与进程名,再发起 actuate 请求——RPA 能力层(`window.findByTitle`)语义不变,只是解析下沉到宿主。
2. **进程模型从"每命令一进程"变"单请求单响应"**。PS 版每个原语起一个 powershell.exe;C# 版一次请求一次响应、宿主超时杀进程(对面现行架构)。RPA 流程里相邻原语本来也无状态依赖(除句柄传递,改由标题精确匹配承担),语义等价。注意:宿主超时必须覆盖 moveSmooth 最坏情况(约 4s)+ 两次证据截图。
3. **副作用证据从无到有**。PS 版动作无留痕;C# 版按对面证据体系在动作**前后各截一张**目标窗口 PNG(PrintWindow,SHA-256 落盘,默认 secret)——这是对面 capability-gateway 审计链要求的,属于增强而非等价项。
## 三、稳定错误码映射(PS 侧 → C# 侧)
| PS 侧失败 | C# actuate 稳定错误码 |
|---|---|
| `找不到窗口:<title>`(throw/RPA_ERROR) | `window_not_found`(无候选)/ `window_ambiguous`(多候选,对面已有语义)/ `window_not_allowed`(不在白名单) |
| `无法把窗口带到前台…`(五招全失败) | `focus_failed` |
| 输入注入 Win32 失败 | `input_failed` |
| 证据截图失败/空白 | `capture_unavailable`(沿用对面现有码) |
| 政策校验失败 | `policy_invalid` / `desktop_session_mismatch`(沿用对面现有码) |
## 四、开关映射:mouse_event → SendInput 标志位
| mouse_event flag | SendInput MOUSEEVENTF | 用途 |
|---|---|---|
| 0x0002 | LEFTDOWN | 左键按下 |
| 0x0004 | LEFTUP | 左键抬起 |
| 0x0008 | RIGHTDOWN | 右键按下 |
| 0x0010 | RIGHTUP | 右键抬起 |
| 0x0800 | WHEEL | 滚轮(data=delta,120 的倍数) |
+31
查看文件
@@ -0,0 +1,31 @@
# windows-runner-actuate 原型补丁包
**用途**:把 RPA 桌面驱动(`packages/rpa-core/src/driver/windows/windows-driver.ts`,PowerShell 实现)的鼠标/键盘/窗口执行能力移植为对面 Agent-Employee `windows-runner` 的 C# 原生实现——即 ADR-R3(C# 路线)的第一块实体。
**状态**:本地原型,未合入对面主干。对面 backend/README Windows 行已明确下一步为"click/type、视觉/OCR 或随本轮客户端交付",本补丁正是其路线图的第一步。
## 内容清单
| 文件 | 内容 |
|---|---|
| `PORTING-MAP.md` | PowerShell → C# 逐原语移植对照表:三处语义差异(模糊→精确匹配、进程模型、证据增强)、错误码映射、mouse_event→SendInput 标志位映射 |
| `native/ActuateProtocol.cs` | actuate 协议扩展类型(请求/动作/结果),复用对面 DesktopPolicy/EvidenceRef/ObservedWindow |
| `native/NativeInput.cs` | 输入类 P/Invoke + **抢前台五招兜底逐行移植**(RPA 实战参数,一行语义不改) |
| `native/ActuateService.cs` | 执行服务:安全门与 observe 同构(白名单/Session/精确匹配),10 种动作闭集分发,动作前后证据截图,平滑移动公式逐行保真 |
| `PATCH-NOTES.md` | 合入指引:Program.cs 最小 diff、TS 宿主适配要点、验证计划、**门禁合规硬性清单** |
## 设计红线(对面安全模型的尊重项)
1. `NativeWindows.cs` 只读编译边界不动——输入类 P/Invoke 独立成 `NativeInput.cs`;
2. 安全门一个不少:进程文件名白名单正则、窗口精确匹配、OS Session 三重核验后才允许输入;
3. 副作用证据:动作前后各一张窗口 PNG(PrintWindow + SHA-256,默认 secret),接入对面证据体系;
4. 稳定错误码进 stdout,系统路径/控件文字不回显;
5. 单请求单响应、超时杀进程,遵守对面进程模型。
## 交接方式
按 `PATCH-NOTES.md` 执行:3 个新文件 + Program.cs 最小 diff + TS 适配器后续工作。合入提交必须带事实文件与文档区块更新(对面 2026-11-06 门禁),清单见 PATCH-NOTES 第 5 节。
## 验证状态
**已编译通过 + 协议冒烟通过**(2026-11-06,.NET SDK 10.0.403):对面 csproj 原样 + 本补丁三文件 + Program.cs 最小 diff → `dotnet build` 0 警告 0 错误;五例零桌面交互协议冒烟全部符合预期(安全门错误码、双路分发、requestId 回显、observe 无回归)。真实窗口用例留待对面 `test/fixture` 集成测试。
@@ -0,0 +1,57 @@
using System.Text.Json.Serialization;
namespace AgentWorker.WindowsRunner;
// actuate 协议扩展提案:在不动 observe 契约的前提下增加执行类操作。
// 设计原则与 observe 完全一致:单请求单响应、稳定错误码、宿主固定证据根目录。
// 本文件只新增类型;Program.cs 的分发改造见 PATCH-NOTES.md(diff 指引)。
//
// 与 observe 的关键差异(由 RPA 桌面驱动语义决定):
// 1. Action 携带具体执行参数(坐标/文本/命令),全部字段服务端可审计;
// 2. 坐标一律物理屏幕像素,与 RPA 驱动现状及 CoordinateSpace 契约一致;
// 3. WindowTitle 必须是 AllowedWindows 的精确成员(模糊匹配在 TS 宿主 observe 阶段完成解析),
// RPA 侧"标题包含匹配"的旧语义不进入原生层。
// 操作闭集:mouse_move / mouse_click / mouse_drag / mouse_scroll / mouse_move_smooth /
// keyboard_send / window_activate / window_set_state / window_set_bounds / window_close。
// 与 observe 相同:模型不能直接指定 HWND;证据目录来自宿主启动参数。
internal sealed record ActuateRequest(
int ProtocolVersion,
string RequestId,
string Operation,
string WindowTitle,
DesktopPolicy Policy,
ActuateAction Action);
// 每个字段都可被逐项审计;不支持的 Kind 由原生层拒绝,不静默降级为观察。
internal sealed record ActuateAction(
string Kind,
int? X,
int? Y,
int? ToX,
int? ToY,
// 滚轮 delta(120 的倍数,负值向下)或窗口 ShowWindow 命令值。
int? Amount,
// keyboard_send 文本;上限由 64 KiB 请求预算约束。
string? Text,
// keyboard_send 三模式:"clipboard"(粘贴)/ "chars"(逐字符)/ "keys"(整段 SendKeys)。
string? Mode,
int? IntervalMs,
bool? Double,
// "left"(默认)或 "right"。
string? Button,
// mouse_move_smooth:false=匀速直线,true/缺省=缓入缓出+抖动。
bool? Ease,
// mouse_move_smooth 速度 1-100(默认 35,与 RPA 驱动一致)。
int? Speed,
// window_set_bounds 用(x/y 复用 X/Y,Amount 不用)。
int? Width,
int? Height,
int? HoldMs,
int? ReleaseDelayMs,
int? SettleMs);
// 动作结果:证据前后各一张,宿主 TS 适配器按 EvidenceRef 逐项复核。
// Data 与 ErrorCode 互斥,与 RunnerResponse 同规则。
internal sealed record ActuateResult(string ActionKind, string CapturedAt,
ObservedWindow ObservedWindow, IReadOnlyList<EvidenceRef> Evidence, int DurationMs);
@@ -0,0 +1,279 @@
using System.Diagnostics;
using System.Drawing;
using System.Drawing.Imaging;
using System.Globalization;
using System.Security.Cryptography;
using System.Text.RegularExpressions;
using System.ComponentModel;
namespace AgentWorker.WindowsRunner;
// actuate 执行服务:把 RPA 桌面驱动(PowerShell 实现)的鼠标/键盘/窗口操作
// 移植进对面 windows-runner 协议,安全门与 observe 完全同构。
// 移植来源:packages/rpa-core/src/driver/windows/windows-driver.ts(对照表见 PORTING-MAP.md)。
//
// 安全边界(与 WindowObservationService 同级):
// - 进程白名单(文件名正则)+ 窗口精确匹配 + OS Session 三重核验后才允许输入;
// - 只操作本用户交互 Session 的一个顶层窗口,不扫描其它桌面;
// - 后续动作前仍重新枚举窗口,不信任上一次请求的 HWND;
// - 副作用证据:动作前后各一张目标窗口 PNG(PrintWindow + SHA-256,默认 secret);
// - 非法 Kind 拒绝执行,不静默降级为观察;
// - 稳定错误码进 stdout,系统路径与控件文字不回显。
internal static partial class ActuateService
{
[GeneratedRegex("^[A-Za-z0-9._-]+\\.exe$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessNamePattern();
private const long MaxWindowPixels = 16_000_000;
private const long MaxPngBytes = 8 * 1024 * 1024;
private sealed record WindowCandidate(nint Handle, string ProcessName, string Title);
// DPI 感知一次性声明:与 RPA 驱动每脚本 SetProcessDpiAwarenessContext(-4) 等价,
// 保证此后 SetCursorPos/GetCursorPos 全程物理像素语义。
internal static ActuateResult Actuate(ActuateRequest request, string evidenceRoot)
{
NativeInput.EnsureDpiAwareness();
ValidatePolicy(request);
var selected = SelectWindow(request.WindowTitle, request.Policy);
if (!NativeWindows.GetWindowRect(selected.Handle, out var nativeRect))
throw new RunnerException("window_unavailable");
var bounds = new WindowRect(nativeRect.Left, nativeRect.Top,
nativeRect.Right - nativeRect.Left, nativeRect.Bottom - nativeRect.Top);
if (bounds.Width < 1 || bounds.Height < 1 ||
(long)bounds.Width * bounds.Height > MaxWindowPixels)
throw new RunnerException("window_size_unsupported");
var startedAt = DateTimeOffset.UtcNow;
var before = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, startedAt);
Execute(request.Action, selected);
var finishedAt = DateTimeOffset.UtcNow;
var after = CaptureWindowEvidence(selected.Handle, bounds, evidenceRoot, finishedAt);
NativeWindows.GetWindowThreadProcessId(selected.Handle, out var pid);
var observed = new ObservedWindow(selected.ProcessName, selected.Title,
$"0x{selected.Handle.ToInt64():X}",
selected.Handle == NativeWindows.GetForegroundWindow());
return new ActuateResult(request.Action.Kind, finishedAt.ToString("O", CultureInfo.InvariantCulture),
observed, [before, after],
(int)(finishedAt - startedAt).TotalMilliseconds);
}
// 与 observe 的 ValidatePolicy 同构:两个白名单均非空、进程名仅接受文件名、
// WindowTitle 精确成员、OS Session 与本进程一致。actuate 不放宽任何一项。
private static void ValidatePolicy(ActuateRequest request)
{
var policy = request.Policy;
if (policy is null || policy.AllowedProcesses is null || policy.AllowedWindows is null ||
policy.AllowedProcesses.Length == 0 || policy.AllowedWindows.Length == 0 ||
policy.AllowedProcesses.Any(name => !ProcessNamePattern().IsMatch(name) || name.Contains("..")) ||
policy.AllowedWindows.Any(title => string.IsNullOrWhiteSpace(title) || title.Length > 256))
throw new RunnerException("policy_invalid");
if (string.IsNullOrWhiteSpace(request.WindowTitle) || request.WindowTitle.Length > 256 ||
!policy.AllowedWindows.Contains(request.WindowTitle, StringComparer.Ordinal))
throw new RunnerException("window_not_allowed");
var currentSession = Process.GetCurrentProcess().SessionId;
if (currentSession == 0 || policy.DesktopSessionId != currentSession.ToString(CultureInfo.InvariantCulture))
throw new RunnerException("desktop_session_mismatch");
if (request.Action is null || string.IsNullOrWhiteSpace(request.Action.Kind))
throw new RunnerException("action_invalid");
}
// 与 observe 的 SelectWindow 同构:枚举本 Session 顶层窗口,精确标题 + PID 回查进程名。
// 同名同进程窗口多于一个时报 ambiguous,不任选——副作用落错对象不可接受。
private static WindowCandidate SelectWindow(string title, DesktopPolicy policy)
{
var candidates = new List<WindowCandidate>();
var session = Process.GetCurrentProcess().SessionId;
_ = NativeWindows.EnumWindows((handle, _) =>
{
if (!NativeWindows.IsWindowVisible(handle)) return true;
if (NativeWindows.Title(handle) != title) return true;
NativeWindows.GetWindowThreadProcessId(handle, out var pid);
if (pid == 0) return true;
try
{
using var process = Process.GetProcessById(checked((int)pid));
if (process.SessionId != session) return true;
var name = process.ProcessName + ".exe";
if (!policy.AllowedProcesses.Contains(name, StringComparer.OrdinalIgnoreCase))
return true;
candidates.Add(new WindowCandidate(handle, name, title));
}
catch (ArgumentException) { /* 进程已退出;旧 HWND 不能成为目标。 */ }
catch (InvalidOperationException) { /* 进程在枚举期间退出。 */ }
catch (Win32Exception) { /* 受保护进程不能读取。 */ }
return true;
}, nint.Zero);
if (candidates.Count == 0) throw new RunnerException("window_not_found");
if (candidates.Count != 1) throw new RunnerException("window_ambiguous");
return candidates[0];
}
// 动作闭集分发;RPA 驱动 windows-driver.ts 各方法的逐段移植(见 PORTING-MAP.md)。
private static void Execute(ActuateAction action, WindowCandidate window)
{
switch (action.Kind)
{
case "mouse_move":
RequirePoint(action);
if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value))
throw new RunnerException("input_failed");
break;
case "mouse_click":
RequirePoint(action);
if (!NativeInput.SetCursorPos(action.X!.Value, action.Y!.Value))
throw new RunnerException("input_failed");
NativeInput.Click(action.Button == "right", action.Double == true ? 2 : 1,
Math.Max(0, action.SettleMs ?? 0));
break;
case "mouse_drag":
RequirePoint(action);
if (action.ToX is null || action.ToY is null) throw new RunnerException("action_invalid");
NativeInput.DragSequence(action.Button == "right", action.X!.Value, action.Y!.Value,
action.ToX.Value, action.ToY.Value,
Math.Max(0, action.HoldMs ?? 80), Math.Max(0, action.ReleaseDelayMs ?? 80));
break;
case "mouse_scroll":
if (action.Amount is null || action.Amount == 0) throw new RunnerException("action_invalid");
NativeInput.SendWheel(action.Amount.Value);
break;
case "mouse_move_smooth":
RequirePoint(action);
SmoothMove(action);
break;
case "keyboard_send":
if (string.IsNullOrEmpty(action.Text)) throw new RunnerException("action_invalid");
// 输入必须落在已核验窗口:先抢前台(五招兜底),对应 RPA 驱动
// activateTitle 选项 + 300ms 间隙;窗口已在前台时五招直接通过。
NativeInput.FocusChain(window.Handle);
Thread.Sleep(300);
SendKeyboard(action);
break;
case "window_activate":
NativeInput.FocusChain(window.Handle);
break;
case "window_set_state":
if (action.Amount is null) throw new RunnerException("action_invalid");
_ = NativeInput.ShowWindow(window.Handle, action.Amount.Value);
break;
case "window_set_bounds":
if (action.X is null || action.Y is null ||
action.Width is null || action.Height is null ||
action.Width < 1 || action.Height < 1)
throw new RunnerException("action_invalid");
NativeInput.MoveWindowBounds(window.Handle, action.X.Value, action.Y.Value,
action.Width.Value, action.Height.Value);
break;
case "window_close":
NativeInput.CloseWindow(window.Handle);
break;
default:
throw new RunnerException("action_invalid");
}
}
private static void RequirePoint(ActuateAction action)
{
if (action.X is null || action.Y is null) throw new RunnerException("action_invalid");
}
// keyboard 三模式(RPA 驱动 KeyboardDriver.send 的移植):
// clipboard:原文进剪贴板 + Ctrl+V(不做 SendKeys 转义,适合长文本);
// chars:逐字符 SendWait(每个字符按 SendKeys 规则转义,intervalMs 控速);
// keys:整段 SendWait 原样发送(保留 ^+% 等组合语义,供按键组合类能力使用)。
private static void SendKeyboard(ActuateAction action)
{
switch (action.Mode)
{
case "clipboard":
Clipboard.SetDataObject(action.Text!, true);
Thread.Sleep(120);
SendKeys.SendWait("^v");
break;
case "chars":
var interval = Math.Max(0, action.IntervalMs ?? 30);
foreach (var character in action.Text!)
{
SendKeys.SendWait(EscapeSendKeys(character));
if (interval > 0) Thread.Sleep(interval);
}
break;
default: // "keys"
SendKeys.SendWait(action.Text!);
break;
}
}
// RPA 驱动 sendKeysEscape 的逐字符移植:SendKeys 特殊字符用花括号表示字面量。
private static string EscapeSendKeys(char character) =>
"+^%~(){}[]".Contains(character) ? $"{{{character}}}" : character.ToString();
// mouse.moveSmooth 的移植:从当前光标位置出发,按 RPA 驱动 mousePath 公式插值。
// 公式(缓入缓出 + 垂直抖动)逐行保真;JS 与 C# 的 % 对负数同为保号语义。
private static void SmoothMove(ActuateAction action)
{
var from = NativeInput.CursorPosition();
var toX = action.X!.Value;
var toY = action.Y!.Value;
var dx = toX - from.X;
var dy = toY - from.Y;
var distance = Math.Sqrt((double)dx * dx + (double)dy * dy);
if (distance < 2) return;
var stepCount = Math.Max(6, Math.Min(50, (int)Math.Round(distance / 12)));
var ease = action.Ease != false;
var speed = Math.Clamp(action.Speed ?? 35, 1, 100);
var delay = Math.Clamp((int)Math.Round(1500.0 / speed), 4, 80);
for (var i = 1; i <= stepCount; i++)
{
var t = (double)i / stepCount;
var easeFactor = ease ? (t < 0.5 ? 2 * t * t : 1 - Math.Pow(-2 * t + 2, 2) / 2) : t;
var jitter = ease ? Math.Sin(t * Math.PI) * (Math.Sin(i * 12.9898) * 43758.5453 % 1) * 3 : 0;
var x = (int)Math.Round(from.X + dx * easeFactor - (dy / distance) * jitter);
var y = (int)Math.Round(from.Y + dy * easeFactor + (dx / distance) * jitter);
if (!NativeInput.SetCursorPos(x, y)) throw new RunnerException("input_failed");
Thread.Sleep(delay);
}
if (!NativeInput.SetCursorPos(toX, toY)) throw new RunnerException("input_failed");
}
// 证据采集:与 observe 的 CaptureWindow 同构(PrintWindow → PNG → SHA-256)。
// actuate 始终留证据(副作用审计需要);PrintWindow 语义与像素有效性检查逐行保真。
private static EvidenceRef CaptureWindowEvidence(nint handle, WindowRect bounds,
string evidenceRoot, DateTimeOffset capturedAt)
{
var path = Path.Combine(evidenceRoot, $"actuate-{Guid.NewGuid():N}.png");
var stamp = capturedAt.ToString("O", CultureInfo.InvariantCulture);
try
{
using var bitmap = new Bitmap(bounds.Width, bounds.Height);
using var graphics = Graphics.FromImage(bitmap);
var deviceContext = graphics.GetHdc();
bool captured;
try { captured = NativeWindows.PrintWindow(handle, deviceContext, 2); }
finally { graphics.ReleaseHdc(deviceContext); }
if (!captured || IsBlank(bitmap)) throw new RunnerException("capture_unavailable");
bitmap.Save(path, ImageFormat.Png);
var size = new FileInfo(path).Length;
if (size < 1 || size > MaxPngBytes) throw new RunnerException("capture_size_unsupported");
using var stream = File.OpenRead(path);
var hash = Convert.ToHexString(SHA256.HashData(stream)).ToLowerInvariant();
return new EvidenceRef($"ev_{hash[..24]}", "window_snapshot",
new Uri(path).AbsoluteUri, hash, "secret", stamp);
}
catch
{
if (File.Exists(path)) File.Delete(path);
throw;
}
}
private static bool IsBlank(Bitmap bitmap)
{
var first = bitmap.GetPixel(0, 0).ToArgb();
for (var row = 0; row < 10; row++)
for (var column = 0; column < 10; column++)
if (bitmap.GetPixel(column * (bitmap.Width - 1) / 9,
row * (bitmap.Height - 1) / 9).ToArgb() != first) return false;
return true;
}
}
@@ -0,0 +1,212 @@
using System.Runtime.InteropServices;
namespace AgentWorker.WindowsRunner;
// 输入类 P/Invoke 独立成类:NativeWindows 保持对面"只读编译边界"原样不动。
// 本层没有进程启动接口;所有输入只作用于经策略核验后的前台窗口。
// 坐标一律物理屏幕像素——进程入口(ActuateService.EnsureDpiAwareness)声明
// Per-Monitor V2 DPI 感知,与 RPA 侧 PowerShell 驱动每脚本声明的
// SetProcessDpiAwarenessContext(-4) 语义一致,保证 SetCursorPos 全程物理像素。
// 抢前台五招兜底从 RPA 驱动 activateWindowScript 逐行移植:
// Windows 前台锁定策略会拒绝普通 SetForegroundWindow(应用点击"运行"后自身是前台),
// 五招顺序与睡眠时间是踩坑调出来的实战参数,一行语义都不允许改动。
internal static class NativeInput
{
private const uint InputMouse = 0;
private const uint MouseEventfMove = 0x0001;
private const uint MouseEventfLeftDown = 0x0002;
private const uint MouseEventfLeftUp = 0x0004;
private const uint MouseEventfRightDown = 0x0008;
private const uint MouseEventfRightUp = 0x0010;
private const uint MouseEventfWheel = 0x0800;
internal const int SwRestore = 9;
internal const int SwMinimize = 6;
internal const int SwMaximize = 3;
private const uint WmClose = 0x0010;
private const byte VkMenu = 0x12; // ALT
private const uint KeyeventfKeyup = 0x0002;
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetCursorPos(int x, int y);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetCursorPos(out Point point);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool SetForegroundWindow(nint handle);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool ShowWindow(nint handle, int command);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool IsIconic(nint handle);
[DllImport("user32.dll")]
internal static extern void keybd_event(byte virtualKey, byte scanCode, uint flags, nint extraInfo);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool AttachThreadInput(uint currentThread, uint targetThread, bool attach);
[DllImport("user32.dll")]
private static extern void SwitchToThisWindow(nint handle, bool altTab);
[DllImport("kernel32.dll")]
private static extern uint GetCurrentThreadId();
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetProcessDpiAwarenessContext(nint value);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool SetWindowPos(nint handle, nint after, int x, int y,
int width, int height, uint flags);
[DllImport("user32.dll")]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool PostMessage(nint handle, uint message, nint wParam, nint lParam);
[DllImport("user32.dll", SetLastError = true)]
private static extern uint SendInput(uint count, Input[] inputs, int size);
[StructLayout(LayoutKind.Sequential)]
internal struct Point { public int X; public int Y; }
[StructLayout(LayoutKind.Sequential)]
private struct Input
{
public uint Type;
public InputUnion Union;
}
[StructLayout(LayoutKind.Explicit)]
private struct InputUnion
{
[FieldOffset(0)] public MouseInput Mouse;
}
[StructLayout(LayoutKind.Sequential)]
private struct MouseInput
{
public int Dx;
public int Dy;
public uint MouseData;
public uint Flags;
public uint Time;
public nint ExtraInfo;
}
// DPI 感知声明是进程级一次性设置;失败(如已被宿主先行声明)不影响后续调用。
internal static void EnsureDpiAwareness()
{
try { _ = SetProcessDpiAwarenessContext(new nint(-4)); }
catch { /* 旧系统无此 API 时按物理像素继续;对面 CoordinateSpace 以 GetDpiForWindow 实测为准。 */ }
}
// SendInput 批量提交鼠标标志组合;单次失败按 input_failed 处理由上层转稳定错误码。
internal static void SendMouse(uint flags, uint data)
{
var input = new Input
{
Type = InputMouse,
Union = new InputUnion
{
Mouse = new MouseInput { Dx = 0, Dy = 0, MouseData = data, Flags = flags }
}
};
if (SendInput(1, [input], Marshal.SizeOf<Input>()) != 1)
throw new RunnerException("input_failed");
}
internal static void SendWheel(int amount) =>
SendMouse(MouseEventfWheel, unchecked((uint)amount));
internal static void Click(bool right, int repeats, int settleMs)
{
var down = right ? MouseEventfRightDown : MouseEventfLeftDown;
var up = right ? MouseEventfRightUp : MouseEventfLeftUp;
for (var i = 0; i < repeats; i++)
{
SendMouse(down, 0);
SendMouse(up, 0);
if (settleMs > 0) Thread.Sleep(settleMs);
}
}
internal static Point CursorPosition()
{
if (!GetCursorPos(out var point)) throw new RunnerException("input_failed");
return point;
}
// 窗口拖拽时序:按下→hold→移动→release→抬起,与 RPA 驱动逐段一致。
internal static void DragSequence(bool right, int fromX, int fromY, int toX, int toY,
int holdMs, int releaseDelayMs)
{
var down = right ? MouseEventfRightDown : MouseEventfLeftDown;
var up = right ? MouseEventfRightUp : MouseEventfLeftUp;
if (!SetCursorPos(fromX, fromY)) throw new RunnerException("input_failed");
SendMouse(down, 0);
if (holdMs > 0) Thread.Sleep(holdMs);
if (!SetCursorPos(toX, toY)) throw new RunnerException("input_failed");
if (releaseDelayMs > 0) Thread.Sleep(releaseDelayMs);
SendMouse(up, 0);
}
// 抢前台五招兜底(RPA 驱动 activateWindowScript 的逐行移植):
// ①最小化则恢复 +150ms ②SwitchToThisWindow +150ms ③ALT 借位 + SetForegroundWindow +200ms
// ④AttachThreadInput 挂到前台线程 + SetForegroundWindow +200ms ⑤最小化再恢复 +450ms
// 五招全失败抛 focus_failed——对应 PS 版 RPA_ERROR '无法把窗口带到前台'。
internal static void FocusChain(nint handle)
{
if (IsIconic(handle))
{
_ = ShowWindow(handle, SwRestore);
Thread.Sleep(150);
}
if (NativeWindows.GetForegroundWindow() != handle)
{
SwitchToThisWindow(handle, true);
Thread.Sleep(150);
}
if (NativeWindows.GetForegroundWindow() != handle)
{
keybd_event(VkMenu, 0, 0, nint.Zero);
_ = SetForegroundWindow(handle);
keybd_event(VkMenu, 0, KeyeventfKeyup, nint.Zero);
Thread.Sleep(200);
}
if (NativeWindows.GetForegroundWindow() != handle)
{
// 返回值即前台窗口的线程号;PID 出参不需要。
var foregroundThread = NativeWindows.GetWindowThreadProcessId(
NativeWindows.GetForegroundWindow(), out _);
var currentThread = GetCurrentThreadId();
var attached = AttachThreadInput(currentThread, foregroundThread, true);
try { _ = SetForegroundWindow(handle); }
finally { if (attached) _ = AttachThreadInput(currentThread, foregroundThread, false); }
Thread.Sleep(200);
}
if (NativeWindows.GetForegroundWindow() != handle)
{
_ = ShowWindow(handle, SwMinimize);
Thread.Sleep(150);
_ = ShowWindow(handle, SwRestore);
Thread.Sleep(300);
}
if (NativeWindows.GetForegroundWindow() != handle)
throw new RunnerException("focus_failed");
}
internal static void MoveWindowBounds(nint handle, int x, int y, int width, int height) =>
_ = SetWindowPos(handle, nint.Zero, x, y, width, height, 4); // flags=4 SWP_NOZORDER,与 RPA 驱动一致
internal static void CloseWindow(nint handle) =>
_ = PostMessage(handle, WmClose, nint.Zero, nint.Zero);
}
+79
查看文件
@@ -0,0 +1,79 @@
# RPA 迁移后续工作完整清单(2026-11-06 定稿)
> 前提:阶段 0 前置作业 + 阶段 1 收口 + 本仓库代码活已 100% 完成(含 C# actuate 原型、A++ 字段、NodeState 两态)。本清单是"接下来做什么"的唯一权威账本,按可动性分四档。更新规则:完成一项就划掉并记提交号;新工作先进"待评估"再入档。
## 三个"不知道"的设计提案(已定稿,发对面确认即可,不再阻塞)
### 提案 1(原 Q1):平台字段怎么表达
对面 plugin-contract 的 `CapabilityDefinition` 增可选字段 `platforms?: readonly ("win32"|"darwin"|"linux")[]`:
- **缺省 = 全平台**——旧插件清单不加字段,零破坏;
- `assertPluginManifest` 校验值域,未知平台值直接拒绝;
- plugin-worker 认领动作时按宿主 `process.platform` 过滤,不匹配的能力对宿主不可见;
- **RPA 插件清单不手工维护**:rpa-adapter 从 rpa-core catalog 的 `platformTag` 自动生成(687 win32 / 326 null 单一事实源)。
### 提案 2(原 Q2):utilityExtended 权限归属
utilityExtended 在对面源码中无同名物(纯 RPA 侧能力域)。**映射到对面 13 项 PermissionScope 的 `desktop.process`**:
- 理由:RPA 命令执行发生在用户电脑 Electron 宿主进程内,"宿主即桌面进程",与 `desktop.process` 语义吻合;
- 该域全部标 `highImpact + idempotencyRequired`(走审批,与 ADR-R1/R6 一致);
- 备选:若对面坚持 desktop.process 严格指"桌面进程管理",则新增 `desktop.command` scope(对面 plugin-contract 加一个 union 成员 + Set 一行)。
### 提案 3:晨间助手截图时机 —— **已搁置(2026-11-06 用户拍板,不再作为工作项)**
不设专门时机,若未来重启则全挂既有证据节奏:observe 基线 + 人审弹窗前证据 + actuate 动作后自动双证据。当前不要求对面任何配合。
### 提案 4:rpa.browser 备案单(发给对面即可登记)
| 项 | 值 |
|---|---|
| pluginId | `rpa.browser` |
| capabilities | `rpa.flow.execute`(RPA 流程整体入口,方案 A)|
| runner | 双 runner:对面 PlaywrightBrowserRunner(AI 智能体场景)+ 我方 CDP 接管(RPA 场景主力,ADR-R4)|
| PermissionScope | `browser.domain`(白名单复用对面 BrowserContextPolicy 三重复检)+ `filesystem.download`(下载类流程)|
| manifest 类型 | browser 能力按对面现行 browser-plugin 口径(域名白名单强校验)|
| 证据 | snapshot/截图走对面证据体系(SHA-256 落盘),不自建 |
---
## 第一档:本周就能做(不依赖对面)
- [ ] **W1 发提案包**:上面 4 个提案 + 6 项 ADR 结论,一条消息发对面负责人,等书面确认(Q1/Q2 提案已写入 V2 §10)
- [x] ~~W2 C# actuate 合入对面主干~~:**已完成(2026-11-06,对面 b646d4d9)**——三新文件+Program.cs 分发改造+RunnerResponse 可选字段;dotnet build 0w0e;零桌面冒烟 5 例全过(policy_invalid/desktop_session_mismatch/window_not_found/observe 回归/request_invalid);document-facts 刷新 10 区块 factRevision=2026-11-06.2;ci-gate fast 仅余 opencode fork 漂移旧账
- [x] ~~W3 rpa.browser 侧准备~~:**已完成(2026-11-06,本仓库 08e798c + 对面 440f7f4f)**——①transport 单命令超时(默认 30s,稳定码 timeout,pending 不再泄漏);②selectorOf 三份重复定义收敛为 src/main/browser/selector.ts(键并集补 uiWindowSelector;browser-common 位置感知版有意保留);宿主 49/49+引擎 32/32 全绿
## 第二档:等对面书面确认后立即动(预计 1-2 天内解锁)
- [x] ~~W4 git subtree 迁包~~:**已完成(2026-11-06,对面 8085ff47+4e345304)**——rpa-core → `code/backend/packages/rpa-core`(@agent-worker/rpa-core),测试按对面规范移 test/,48/48 全绿;feat/rpa-core-migration 共 5 笔提交(含 attempt 831e25f9、actuate b646d4d9、transport 440f7f4f)已推远端,待主控审核
- [ ] **W5 rpa-adapter 骨架**(对面仓库新建):CapabilityRunner(gateway.execute → ref→componentId → RunContext → handler)+ 权限映射表(34 域 × 13 scope,utilityExtended→desktop.process)+ 状态映射直接 import 本方 `toStepStatus()` + 719 能力"未经 authorize 不得触达 handler"全量冒烟
- [ ] **W6 platform 字段实现**(对面 plugin-contract):按提案 1,registry 校验 + worker 过滤 + 契约测试
## 第三档:阶段 3+(前置:W4/W5 完成)
- [ ] **W7 垂直切片**:1 文件能力 + 1 HTTP 能力全链(create/start → authorize → adapter → handler → 事件/证据回传 → 中途取消);Run 终态与 RunResult 一致性断言
- [ ] **W8 A++ 检查点最小验证**:崩溃 → resumeToken 重入 → 幂等键去重(`checkpointId`/`idempotencyKey` 已定稿,只差实现)
- [ ] **W9 双 ADR 落地**:R4 浏览器(W3 产物接对面白名单)+ R3 桌面(W2 产物接 TS 宿主适配器——actuate 前先 observe 解析精确标题)
- [ ] **W10 TS 宿主 actuate 适配器**:native-observer.ts 扩展(请求侧精确标题解析、响应侧证据逐项复核、宿主超时 ≥15s)
## 第四档:规模化与收尾(阶段 5/6)
- [ ] **W11 34 域分批接入**:files/data/http/ftp → office/excel/word → desktop/utility 高影响(末批 + 审批矩阵抽查)
- [ ] **W12 ocr-smoke 素材治理**(迁移时**必做**,非可选):素材入库 `test-fixtures/ocr/` + evidenceDir 改 `os.tmpdir()`——硬编码 `D:/office-test2` 在对面机器必然失效
- [ ] **W13 平台扩展**:Linux/macOS 驱动按 ADR-R3 原生通道(阶段 6)
- [ ] **W14 demo 侧冻结**:本 demo 仓库转入只读维护
## 挂账小项(低优先)
- [x] ~~晨间助手截图时机~~:已搁置(2026-11-06 用户拍板),见提案 3 说明
- [ ] `.workbuddy/tmp/actuate-build` 编译沙盒:留作 W2 合入前复验环境,合入后可删
## 里程碑对照
| 里程碑 | 依赖 | 预计解锁 |
|---|---|---|
| Q1/Q2/备案 书面确认 | W1 | 第二档全部 |
| 对面仓库可提交 | W1 | W2/W4/W5/W6 |
| 垂直切片全链绿 | W4-W7 | 阶段 4(W8-W10)|
| 高影响域过审批矩阵 | W11 | 阶段 5 完成即"接入就绪" |
+56
查看文件
@@ -0,0 +1,56 @@
# PR 草稿:feat/rpa-core-migration(线4·RPA插件迁入)
> 用途:对面仓库开 PR 时直接粘贴。按对面 `.github/PULL_REQUEST_TEMPLATE.md`(699df63d 版)字段填写,同时补齐迁入提交 8085ff47 单行提交说明未承载的细节。
## 基本信息
- 分支:`feat/rpa-core-migration`(未直接向 main 提交/合并)
- 基线 SHA:`1a56a084`(origin/main 最新,直系子提交,无分叉)
- 最终提交 SHA:`4e345304`
- 关联需求/ADR/待办:线4·RPA融合第一步——迁入 RPA 核心包;设计依据见自仓库 `docs/rpa-core-migration-guide-v2.md`
- 前置 PR/任务:无(独立迁入)
- 后置 PR/任务:`rpa-adapter` 适配层(线4,depends-on: 本 PR;待 Q5 配置面/审批上游确认后开工)
- 共享入口 owner:`code/backend/package.json` workspace 清单(本 PR 未改它,仅 bun.lock 新增依赖锁);catalog 资源目录 `code/backend/resources/` owner=线4
## 代码逻辑
- 输入如何进入:RunRequest(流程 JSON + 输入参数)→ `createEngine` → `registerAll` 能力注册表 → 引擎逐节点执行。能力覆盖桌面(Windows 原生驱动)/浏览器/Excel/PDF/HTTP/文件/屏幕等,注册表 700+ 条目,零 Electron 依赖。
- 状态如何变化:NodeState.status 流转(idle/pending/running/succeeded/failed/skipped/waiting_user/unknown);RunEvent 事件流携带 `checkpointId`/`idempotencyKey`(=runId:nodeId)/`resumable`/`executor` 审计字段。
- 谁最终判权:本包不做权限判定。执行授权由宿主下发(对面 gateway 租约 + 每节点单独授权,粒度拍板已落档);包内仅做 capabilityVersion 三段 semver 校验。
- 数据如何保存/回读:包内无独立落库;检查点与运行记录由宿主持久化,包只产出事件与状态。
- 失败/重试/取消/并发/撤权:节点级稳定错误码;幂等键 runId:nodeId 跨重入稳定,支持检查点续跑(resumable 门控:idempotent===false 不作锚点);撤权由宿主停止派发,包内无自主重试循环。
- 本次明确不包含:rpa-adapter、AI 调度、双端 executor 路由落地、管理端/客户端 UI、Bun 冒烟(决策10:延期到接宿主时)。
- 触及路径:`code/backend/packages/rpa-core/**`(新增 150 文件:src 128 + test 44 等)、`code/backend/resources/`(catalog.json / catalog-overrides.json / capability-duplicates.json 伴生资源)、`code/backend/bun.lock`、`项目资料/开发方案/当前事实.json`。
- 明确不触及路径:`code/frontend/**`、Java 服务端、Flyway 迁移、权限目录、OpenAPI/生成器、部署 Compose。
- 重复实现检索:`git log origin/main -- code/backend/packages`(无既有 RPA 包);`rg -n "rpa-core|createEngine|registerAll" code`(无重复实现)。复用结论:唯一 owner=线4,无同事并行认领。
## 契约与影响
- SQL/迁移/权限/配置:无 SQL/迁移/权限变化;新增 workspace 包 `@agent-worker/rpa-core` 及 bun.lock 条目。
- 文档事实:`当前事实.json` 已同提交刷新,`document-facts --check` 通过(代码/事实/文档同提交原则已满足)。
- 回滚方式:revert 两个提交即可整包移除;无迁移、无生成物、无跨包引用残留。
- 依赖的迁移/制品/运行版本:**bun 1.4.3**(1.4.4 的 patchedDependencies 打不上 photon-node 补丁,与团队锁定版本一致,不是新要求)。
## 验证证据
- `bun test`(backend workspace):48/48 全绿(engine + excel + pdf + platform-groups + register-all + compat-audit)。
- `node code/tooling/document-facts.mjs --check`:通过。
- `node code/tooling/ci-gate.mjs --mode fast`:通过。
- `node code/tooling/check-workspace`:产品源码树测试文件违规清零。
- 真实环境证据:本 PR 为代码迁入,桌面能力(Windows 原生输入/PrintWindow)需真实 Windows 宿主验收,属后置 rpa-adapter 联调范围;CI 覆盖注册表/catalog/引擎层。
- 冲突解决记录:无冲突(基于 origin/main 最新直系提交)。
## 未完成与风险
- opencode fork 快照漂移:main 既有问题(ci-gate fast 模式旧账),与本 PR 无关,不阻塞审查。
- Windows 桌面能力实机验证未做:随 rpa-adapter 联调补齐。
- 迁入提交 8085ff47 提交说明仅一行(早于 699df63d 模板新规),细节以本 PR 描述为准;第二笔 4e345304 已按新模板填写。
## 提交清单确认
- [x] 已从最新 origin/main 同步(1a56a084)
- [x] 未直接向 main 提交或合并
- [x] 本 PR 只包含一个可审查的纵切目标(RPA 核心包迁入+规范化)
- [x] 已声明前置/后置任务、共享 owner 和依赖顺序
- [x] 没有与其他 PR 重复实现;共享入口只有一个 owner
- [x] 已按第五条线工作流完成自审,等待主控审核和合并
+98 -50
查看文件
@@ -12,17 +12,18 @@
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 侧此轮整改完成了一部分**:平台标记单一源(PLATFORM_GROUPS 34 域 + 守卫测试)、驱动按平台自动选择(platform_unsupported)、catalog platformTag 687 项首次生效、5 个真桌面测试补平台守卫、`driver/platform.ts` 收拢。**但接入前 P0 大部分未解决**:`createEngine()` 仍无注入参数、Registry 仍是进程级全局、超时不中止底层操作、取消被标为 failed、5 处动态 `import("electron")` 残留、包依赖缺 3 项。
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(授权→租约→幂等→执行→审计),**不得绕行**;
- rpa-core **不得依赖 Electron 主进程**(当前仍有 5 处动态 import,是接入阻塞项)。
5. **6 项 ADR 已拍板 5 项(2026-11-06)**,仅 R2(集成形态)带问题清单去对面对评审,详见 §10。已定基调:
5. **6 项 ADR 全部拍板(2026-11-06)**,仅剩 2 个小问题(Q1/Q2)需对面口头确认,详见 §10。已定基调:
- **R3 桌面执行**:C# 路线——五域执行逻辑移植进 windows-runner 原生进程,PowerShell 通道仅限本 demo。
- **R6 部署拓扑**:按能力域拆分——纯 Node 域进 backend plugin-worker,桌面/视觉/弹窗域装用户电脑 Electron 宿主,同一包按分组加载。
- **R1 能力清单**:全局一份,权限隔离全交对面网关按租户管。
- **R4 浏览器**:按场景分工——RPA 场景用咱们 CDP 接管(复用登录态/真实页面),对面 Playwright 留给 AI 智能体;白名单与证据共用对面一套。
- **R5 消费方式**:源码 git subtree 并入对面仓库统一编译,Bun 锁 1.4.3。
- **R2 集成形态(含 A++ 补记)**:方案 A——整个 RPA 流程注册为 `rpa.flow.execute` 单能力,事件流旁路+人审升级两补丁;事件流再升级为检查点协议(崩溃后带 resumeToken 断点续跑),权威账本始终单 step。两侧改动清单见 §7-A5。
---
@@ -35,34 +36,34 @@
---
## 2. RPA 侧现状事实清单(2026-11-06 实测)
## 2. RPA 侧现状事实清单(2026-11-06 实测;★=阶段 1 已于当日修复)
| # | 事项 | 状态 | 证据 |
|---|---|---|---|
| 1 | Driver 惰性 getter + 非 win32 抛 `platform_unsupported` | 已实现 | engine.ts:47-61;error-codes.ts:43;engine-driver.test.ts |
| 2 | **Driver 可注入** | **未实现** | `createEngine()` 无参数(engine.ts:64-66);desktop.ts:9 等在模块加载时捕获 `defaultEngine.driver` |
| 3 | Registry 全局 Map、last-wins、runtime 读全局 getHandler | 属实(未改) | registry.ts:36-45;runtime.ts:260 |
| 4 | PLATFORM_GROUPS 单一源(34 域全 win32)+ buildDomainPlatformMap 真实实现 + 守卫测试 | 已实现 | register-all.ts:16-50,98-127;platform-groups.test.ts |
| 5 | withTimeout 监听 signal 但无 AbortController——超时不中止底层 | 属实(未修) | runtime.ts:33-56 |
| 6 | NodeState 类型含 cancelled,但 executeNode catch 把取消标成 failed | 属实(未修) | types.ts:142;runtime.ts:334-339 |
| 7 | 重试上限 100、黑名单式 isRetryable | 属实(未修) | runtime.ts:58-60,276 |
| 8 | Graph Validator | 未实现 | 全仓无 validateFlowGraph |
| 9 | 动态 `import("electron")` 5 处(vision.ts×3、cv-vision.ts×2) | 属实(未修) | vision.ts:18,66,432;cv-vision.ts:51,213 |
| 10 | check-deps.mjs 对 `import("electron")` 零匹配(盲区实测) | 属实 | check-deps.mjs:20 正则要求 `import\s+` |
| 11 | 包依赖缺 3 项:@napi-rs/canvas、pdfjs-dist、@cantoo/pdf-lib | 属实(未修) | rpa-core/package.json:17-32 对比 pdf.ts/vision.ts import |
| 12 | main/exports 直指 src 源码、private | 属实(决策项) | rpa-core/package.json:4-11 |
| 13 | rpa-cli 副作用全量 import + 全局 getHandler | 属实(未改) | scripts/rpa-cli.ts:25,53-59 |
| 14 | CDP transport 单命令无超时/无 AbortSignal/pending 仅断连清理 | 属实(未修) | transport.ts:96-118 |
| 15 | 两段式元素定位(当前页优先,失败才跟前台)| 已实现 | session.ts:296-307,518-523 |
| 16 | locate() 不支持 xpath / Shadow DOM(与 withElement() 不一致)| 属实(未修) | session.ts:465-517 对比 252-287 |
| 17 | HostBridge/SubflowSource 模块级单例 | 属实(未改) | host-bridge.ts:16-24,61-69 |
| 18 | AI ChatCompletionOptions 无 signal 参数 | 属实(未修) | ai-service.ts:39-48 |
| 19 | 桌面拾取无 AbortSignal(热键+轮询+deadline 自有机制)| 属实(未修) | desktop-pick.ts 全文零 signal 命中 |
| 20 | catalog platformTag:win32=687 / null=326 / **数字 1=3(脏值未清洗)** | 部分实现 | build-catalog.mjs:245-246 对 truthy 旧值透传 |
| 21 | catalog overrides(fields/removeFields/addFields) | 已实现 | catalog-overrides.mjs:6-24 |
| 1 | Driver 惰性 getter + 非 win32 抛 `platform_unsupported` | 已实现 | engine.ts;error-codes.ts:43;engine-driver.test.ts |
| 2 | **Driver 可注入** | ★已实现 | `createEngine({driver})` + `engine.setDriver()`;desktop/common-final/system-extended/window-extended 的模块级捕获改惰性 `getDriver()` |
| 3 | Registry 全局 Map、last-wins、runtime 读全局 getHandler | 属实(ADR-R1 短期形态固化;新增 `engine.execute` 语义真实入口) | registry.ts;engine.ts execute() |
| 4 | PLATFORM_GROUPS 单一源(34 域全 win32)+ buildDomainPlatformMap 真实实现 + 守卫测试 | 已实现 | register-all.ts;platform-groups.test.ts |
| 5 | withTimeout 监听 signal 但无 AbortController——超时不中止底层 | ★已修复 | 尝试级 AbortController 汇聚 run 取消+节点超时,handler 收到的 ctx.signal 即尝试信号 |
| 6 | NodeState 类型含 cancelled,但 executeNode catch 把取消标成 failed | ★已修复 | 取消映射 cancelled(不适用 skipOnError、不进异常分支);runtime.cancel.test.ts |
| 7 | 重试上限 100、黑名单式 isRetryable | ★已门控 | CapabilityMeta.idempotent===false 时无视 retryCount |
| 8 | Graph Validator | ★已实现 | engine/graph-validator.ts(重复ID/悬空边/自环/环/孤儿/不可达),runFlow 前置校验 |
| 9 | 动态 `import("electron")` 5 处(vision.ts×3、cv-vision.ts×2) | ★已修复 | HostBridge.screen 服务注入(requireScreenService),Core 恢复零 Electron |
| 10 | check-deps.mjs 对 `import("electron")` 零匹配(盲区实测) | ★已修复 | 新增动态 import()/require() 扫描;合成用例命中=1,真实源码 0 违规 |
| 11 | 包依赖缺 3 项:@napi-rs/canvas、pdfjs-dist、@cantoo/pdf-lib | ★已修复 | rpa-core/package.json dependencies 补齐 |
| 12 | main/exports 直指 src 源码、private | 属实(决策项,与对面源码消费一致) | rpa-core/package.json |
| 13 | rpa-cli 副作用全量 import + 全局 getHandler | ★已重写 | 显式按域注册(5 域)+ engine.execute |
| 14 | CDP transport 单命令无超时/无 AbortSignal/pending 仅断连清理 | 属实(未修,A7 迁移前修) | transport.ts:96-118 |
| 15 | 两段式元素定位(当前页优先,失败才跟前台)| 已实现 | session.ts |
| 16 | locate() 不支持 xpath / Shadow DOM(与 withElement() 不一致)| 属实(未修,A7 迁移前修) | session.ts |
| 17 | HostBridge/SubflowSource 模块级单例 | 属实(ADR-R1 范畴;screen 服务已并入 HostBridge) | host-bridge.ts |
| 18 | AI ChatCompletionOptions 无 signal 参数 | 属实(未修,P2) | ai-service.ts |
| 19 | 桌面拾取无 AbortSignal(热键+轮询+deadline 自有机制)| 属实(未修,P1) | desktop-pick.ts |
| 20 | catalog platformTag:win32=687 / null=326 / 数字 1=3(脏值未清洗) | ✅ 已清洗(2026-11-06) | build-catalog.mjs 增白名单归一化(非 win32/darwin/linux 字符串一律重派生),3 条脏值重派生为 win32,全字段 diff 仅 3 条变化 |
| 21 | catalog overrides(fields/removeFields/addFields) | 已实现(+782 个 options 收割修复重生成回归) | catalog-overrides.mjs |
| 22 | 能力 719 个 / 目录组件 1016(vendor 997 + native 16 + trigger 3)/ fields 4299 / 34 域 | 实测 | platform-groups.test.ts;catalog.json counts |
| 23 | 平台守卫测试 5 文件 + driver/platform.ts 唯一入口 | 已实现 | capabilities/*.test.ts;driver/platform.ts |
| 24 | Driver 五域方法均无 AbortSignal 参数 | 属实(未修) | driver/types.ts:87-119 |
| 24 | Driver 五域方法均无 AbortSignal 参数 | 属实(未修;运行时取消已能中止监听 signal 的能力内部操作) | driver/types.ts |
---
@@ -85,6 +86,8 @@
对面安全模型要点(直接影响 RPA 桌面路线):windows-plugin 策略校验只允许 `*.exe` 进程名、不允许路径;native-observer.ts 设计约束明言"不执行任意 PowerShell";桌面交互 Runner 留宿主、不进 Linux 容器。
**对面协作门禁(2026-11-06 新增,本项目补丁必须遵守)**:对面已建立代码与文档事实一致性门禁——`code/tooling/document-facts.mjs` + `项目资料/开发方案/当前事实.json`(factRevision 2026-11-06.1)。规则:代码、迁移、合同、端口、运行事实的任何变化,必须与事实文件、README 机器生成区块**同一提交**;CI 的 `document-facts --check` / `ci-gate.mjs` 会重扫源码输入并阻断漂移。**因此本项目未来合入对面仓库的任何补丁(含 C# actuate 补丁包、subtree 迁包)须:①同一提交内更新 `当前事实.json` 与相关 README 区块;②提交前跑 `node code/tooling/document-facts.mjs --check` 与 `node code/tooling/ci-gate.mjs --mode fast`。** 另:对面 backend/README Windows 行已明确下一步为"click/type、视觉/OCR 或随本轮客户端交付"——R3 C# actuate 路线与其路线图一致,可据此作为提案依据。
---
## 4. 原指南(V1)内容分类
@@ -94,7 +97,7 @@
| 包现状描述(719 能力、34 域、宿主桥 4 方法、Driver 五域) | **保留**(微调) | 仍准确;宿主桥维持 4 方法不变 |
| "完全不依赖 Electron、check:deps 天天检查" | **过时/不实** | 5 处动态 import 逃过护栏;改为"静态依赖已清、动态残留待清",护栏必须升级 |
| 第 -1 步对面契约包全部空壳的叙事 | **过时** | 按 §3 改写:5/7 已实现,"填契约"变"接底座" |
| 第 0 步三件作业(bun 冒烟 / catalog 重生成 / 注册方式决策) | **部分保留** | catalog 重生成已完成(platformTag 687 生效,3 个脏值待清洗);bun 冒烟仍未做;注册方式决策并入 ADR-R1 |
| 第 0 步三件作业(bun 冒烟 / catalog 重生成 / 注册方式决策) | ✅ 全部完成 | catalog 重生成完成且脏值已清洗(platformTag win32=690 / null=326,数字脏值归零);bun 1.4.4 本机冒烟 37/37;注册方式决策并入 ADR-R1 |
| 第 1 步搬包(git subtree → code/backend/packages/rpa-core、改名 @agent-worker/rpa-core、demo 侧不双活) | **保留** | 双侧命名与 workspace 布局核实无误 |
| 第 2 步 A 类契约对齐表 | **需要修改** | capability-gateway/workflow-engine 从"对照后改对面"变"按已实现接口写 adapter";新增 plugin-contract/registry/worker 三层对接 |
| 第 2 步 B 类宿主注入表(浏览器=最大变数) | **需要修改** | 浏览器变数已收敛:对面有 Playwright 实现;变数改为"双路径取舍"(ADR-R4) |
@@ -152,7 +155,7 @@
| RPA 侧(packages/rpa-core) | Agent-Employee 侧 | 关系 | 现状 |
|---|---|---|---|
| `engine/runtime.ts` 流程执行 | `workflow-engine` WorkflowEngine/StepRun | RPA=执行算法候选,AE=权威状态 | 集成形态待 ADR-R2 |
| `engine/runtime.ts` 流程执行 | `workflow-engine` WorkflowEngine/StepRun | RPA=执行算法候选,AE=权威状态 | 已拍板方案 A(ADR-R2) |
| `engine/registry.ts` 全局注册表 | `plugin-registry` + `capability-gateway` CapabilityRef | ref↔componentId 映射层(rpa-adapter) | 映射待建 |
| 719 能力 handler | `plugin-contract` CapabilityDefinition + PermissionScope 13 项 | 能力清单生成 + 权限映射 | 待建(脚本生成) |
| `RunContext.signal` | `CapabilityExecutionContext.signal` | 均 AbortSignal,直通 | 兼容 |
@@ -180,7 +183,7 @@
- **兼容性**:部分。平台错误语义(platform_unsupported)已就绪;注入面缺失。
- **问题**:宿主无法注入测试替身或 computer-control 适配器;能力文件模块级捕获引擎状态(excelHandles/ftpClient/ocrInstance/driver),多会话共享。
- **改哪侧**:RPA。
- **方案**:`createEngine(options?: { driver?, hostBridge?, subflowSource?, registry? })`,全部可选、缺省保持现行为;能力文件改为经 RunContext/engine 参数取 driver 与资源表(模块级捕获改为首用时向 engine 取);幂等句柄表(excelHandles/ftpClient/ocrInstance)迁入 engine 实例字段(部分已是)。
- **方案(★ 已实现,2026-11-06 阶段 1-A1)**:`createEngine(options?: { driver?, hostBridge?, subflowSource?, registry? })`,全部可选、缺省保持现行为;能力文件改为经 RunContext/engine 参数取 driver 与资源表(模块级捕获改为首用时向 engine 取);幂等句柄表(excelHandles/ftpClient/ocrInstance)迁入 engine 实例字段(部分已是)。
- **验收**:FakeDriver 注入测试覆盖五域;两个 Engine 各注入不同 FakeDriver 并行执行互不串用;非 win32 上 import 全部 34 域不抛错、调用桌面能力时才抛 platform_unsupported。
### A2 Registry 注册机制与实例隔离
@@ -200,7 +203,7 @@
- **兼容性**:不兼容。对面以幂等键 + 租约纪元管理副作用安全;RPA 超时/取消/重试语义与它冲突。
- **问题**:超时后底层 PowerShell/HTTP/CDP 仍执行(重复副作用);取消状态不一致;非幂等能力可被重试 100 次。**这是接入的第一阻塞项。**
- **改哪侧**:RPA。
- **方案**:①节点尝试创建独立 AbortController,合并 Run 级 signal 与节点 timeout,贯通 handler→driver→PowerShell 子进程/HTTP/CDP(driver 五域方法加可选 `signal` 参数);②executeNode catch 优先映射 cancelled → `state.status="cancelled"`,不走 skipOnError/error route/retry;③重试改元数据驱动:CapabilityMeta 增 `idempotent`/`sideEffect`/`retryPolicy`,非幂等默认 0 次重试;④新增 `validateFlowGraph`(ID 唯一、边合法、入口/出口基数、环、悬空边)。
- **方案(★ 已实现,2026-11-06 阶段 1-A3)**:①节点尝试创建独立 AbortController,合并 Run 级 signal 与节点 timeout,贯通 handler→driver→PowerShell 子进程/HTTP/CDP(driver 五域方法加可选 `signal` 参数);②executeNode catch 优先映射 cancelled → `state.status="cancelled"`,不走 skipOnError/error route/retry;③重试改元数据驱动:CapabilityMeta 增 `idempotent`/`sideEffect`/`retryPolicy`,非幂等默认 0 次重试;④新增 `validateFlowGraph`(ID 唯一、边合法、入口/出口基数、环、悬空边)。
- **验收**:构造 5 秒 Fake Handler + 100ms 超时,断言 handler 收到 abort 并停止、无并发重入;执行中取消后 Run 与 NodeState 均为 cancelled;对 File 写入/点击类能力验证"超时不自动重试";非法图执行前得到稳定错误码。
### A4 Capability Gateway(能力注册与权限治理)
@@ -212,6 +215,8 @@
- **改哪侧**:新建 adapter(AE 仓库内包)+ RPA 补元数据。
- **方案**:①rpa-adapter 实现 CapabilityRunner:gateway.execute(lease, runner) → ref→componentId → 构造 RunContext(signal 直通)→ handler → 结果包装 ActionResult;②权限映射表按 34 域 × PermissionScope 初稿生成(files→filesystem.workspace、http→network.http、desktop/windowExtended→desktop.input/window、utilityExtended→独立高影响 scope【待确认:现表无 process 执行域对应,需对面确认】、message→messaging.send);③高风险域标 highImpact 走审批(System_ExecuteCommand、键鼠、文件删除/覆盖、发消息);④meta 增治理字段(与对面 CapabilityDefinition 字段对齐)。
- **验收**:审批拒绝/租约过期/权限不在白名单时 handler **零调用**(gateway 已有测试语义,adapter 层加 719 全量冒烟:未经 authorize 不得触达 handler);审计可关联 Agent/Run/Capability/审批结果。
- **租约粒度拍板(2026-11-06 用户拍板:每个节点执行都要慎重)**:gateway 租约 TTL=60s(index.ts:124),而方案 A 整条流程可达数分钟——**解法定为"外层一个 step、内层每节点一次授权"**:流程内每个节点执行前由 adapter 单独走一次 authorize+execute(60s/节点绰绰有余),高危节点逐节点审批。与 A++ 检查点协议天配:事件负载的 `nodeId + idempotencyKey(runId:nodeId)` 正好构成 gateway 幂等键所需的作用域材料(workflow 来源要求 run+step+attempt,adapter 以 nodeId 充当 step 定位)。每节点授权同时天然实现"慎重"语义:任何节点执行前都过权限白名单+审批门,无绕行。
- **版本管理设计提案(2026-11-06,adapter 层落地,不改引擎)**:对面版本约束为硬性三处——①每次调用必带 `capabilityVersion`,与注册表不符直接拒绝(gateway index.ts:171);②幂等键含能力版本与配置版本;③插件 manifest 强制三段 semver(plugin-contract index.ts:277,`v1.0`/四段式均不通过)。我方现状:catalog 997/1016 项带上游四段版本(如 `App_Run@3.3.5.0`),但注册表无版本机制、包版本 0.1.0 从未随能力变化推进。提案:①对外能力 version = rpa-core 包 semver(0.1.0 起,仅对外行为变化才升版);②catalog 四段版本归一化为三段后进 CapabilityDefinition(版本仅作标识,行为一致性由同包源码保证);③configVersion 用 catalog+overrides 内容哈希(与幂等键 configVersion 段正好衔接)。**对迁包零影响**:catalog 版本字段原样迁移,归一化发生在 adapter 生成清单时。
### A5 Workflow Engine 执行契约
@@ -220,8 +225,12 @@
- **兼容性**:状态映射大体可行;RPA 缺 `waiting_user`/`unknown` 两态;对面 StepStatus 无 RPA 的 `idle`。
- **问题**:两套状态权威冲突风险;集成形态二选一未定(整体作为一个 capability vs 节点级映射为 step)。
- **改哪侧**:adapter + 双侧小改。
- **方案**:**推荐 MVP 方案 A**——整个 RPA 流程注册为**一个** capability(`rpa.flow.execute`),workflow-engine 的一个 step 调它,RunResult→ActionResult、节点级事件→event-bus 事件流、human-dialog→requestApproval 挂对面 approval 流、取消→signal 直通;方案 B(RPA 节点=step 1:1 映射)等方案 A 验收后按需评估。NodeState 增 `waiting_user`/`unknown` 两态(unknown 用于崩溃恢复对齐)。
- **验收**:状态映射契约测试(含 waiting_user/unknown/cancelled 全分支);一次含审批节点、一次执行中取消、一次进程崩溃恢复的端到端演练;对面 Run 状态终态与 RPA RunResult 永远一致。
- **方案**:**已拍板(ADR-R2)方案 A + 两补丁**——整个 RPA 流程注册为**一个** capability(`rpa.flow.execute`),workflow-engine 的一个 step 调它,RunResult→ActionResult、节点级事件→event-bus 事件流(旁路,不进权威账本)、human-dialog→requestApproval 挂对面 approval 流、取消→signal 直通。补丁 1:节点事件流保透明;补丁 2:人审/高危节点照常升级对面审批。方案 B(节点=step 1:1)的触发条件留档:仅当对面产品确认需要"逐步控制 RPA"(单步执行/任意步回退)时局部启用,届时事件流已备好数据不返工。NodeState 增 `waiting_user`/`unknown` 两态(unknown 用于崩溃恢复对齐)。
- **A++ 检查点补记(2026-11-06,本方负责人确认)**:在方案 A 骨架内将节点事件流升级为检查点协议(决策全文见 §10-R2)。
- **RPA 侧改动**:①事件负载增 `nodeId`/`status`/`checkpointId`/`idempotencyKey`/`resumable` 字段;②`rpa.flow.execute` 接受可选 `resumeToken`,重入时从最近检查点续跑(幂等句柄表已迁 engine 实例字段,地基现成);③幂等键生成与传递贯通(复用 A3 的 `idempotent` 门控,仅补 key 生成与透传)。
- **对面侧改动**:①workflow-engine 对 `rpa.flow.execute` 所在 step 的重试/恢复策略允许带 resumeToken 重入(不新建 step、不改租约语义);②event-bus 消费端识别检查点事件并入审计链;③崩溃恢复时把 resumeToken 经 adapter 回传(复用 workflow-recovery 的 unknown→恢复路径)。
- **落地节奏**:阶段 2 只做事件负载字段设计定稿(payload 预留,近零成本);阶段 3 垂直切片做最小验证(崩溃→重入续跑);阶段 4/5 规模化时强制执行。
- **验收**:状态映射契约测试(含 waiting_user/unknown/cancelled 全分支);一次含审批节点、一次执行中取消、一次进程崩溃恢复的端到端演练;对面 Run 状态终态与 RPA RunResult 永远一致;崩溃重入演练——杀掉执行进程后带 resumeToken 续跑,已完成节点不重复执行、幂等键防重复副作用。
### A6 Computer Control 与 Windows 桌面执行
@@ -245,10 +254,10 @@
### A8 平台适配(Windows / Linux / macOS)
- **RPA 位置**:`register-all.ts` PLATFORM_GROUPS(34 域全 win32)、`driver/platform.ts`、`engine/engine.ts:47-61`、5 个 skipIf 测试;catalog 687 win32 / 326 null / **3 个数字 1 脏值**。
- **RPA 位置**:`register-all.ts` PLATFORM_GROUPS(34 域全 win32)、`driver/platform.ts`、`engine/engine.ts:47-61`、5 个 skipIf 测试;catalog **win32=690 / null=326,数字脏值已归零(2026-11-06 白名单归一化)**。
- **AE 位置**:`plugin-contract` Platform 类型;windows-runner 明确"留在宿主、不进 Linux 容器"(README)。
- **兼容性**:兼容(两边都是 win32-first 务实路线)。
- **问题**:①catalog 3 个 `platformTag: 1` 脏值透传(build-catalog.mjs:246 对 truthy 不清洗);②对面 CapabilityDefinition 未见 platform 字段(**待确认**:插件清单如何表达平台限制)。
- **问题**:①~~catalog 3 个 `platformTag: 1` 脏值透传~~ **已修复(2026-11-06)**:build-catalog.mjs 白名单归一化,上游 shizai 的数字 1 重派生为 win32(三条均为包内真实现,语义正确);②对面 CapabilityDefinition 未见 platform 字段(**待确认**:插件清单如何表达平台限制)。
- **改哪侧**:RPA(清洗);对面(清单平台字段,待确认后)。
- **方案**:build-catalog 归一化——源值不在 `win32|darwin|linux|null` 白名单的一律重派生;三端扩展节奏沿用 V1 第三节(Linux xdotool/AT-SPI、Mac AX/CGEvent/TCC、Office 分端实现),驱动实现统一走 ADR-R3 的原生进程通道。
- **验收**:catalog 全量过 schema 校验(1016/1016);非 win32 上 Core 可加载、桌面能力明确报 platform_unsupported;Mac/Linux CI 跑纯逻辑子集。
@@ -260,7 +269,7 @@
- **兼容性**:不兼容。视觉能力在对面 backend 进程必然失败。
- **问题**:Core 不是 Electron-free;护栏查不出动态 import;单包安装缺 3 个依赖。
- **改哪侧**:RPA。
- **方案**:①抽 `ScreenCaptureHost`/`DisplayCoordinateService` 接口并入 HostBridge 注入面(弹窗/DPI×2/AI 配置 4 方法 → 增至 6 方法,或独立 host services 包——实现期定,倾向并入 HostBridge 减少 API 面);vision/cv-vision 只依赖接口,宿主未注入时抛 `host_service_unavailable`;②check-deps 升级为覆盖 `import()`/`require()`/`createRequire()`(先补正则,中期 AST);③补齐 3 个依赖声明(pdf.js 与 canvas 为原生/大件,评估 optionalDependencies + 能力级降级);④消费方式锁定 **Bun 源码消费**(对面全家 workspace:* 源码依赖,与现状一致),锁 bun@1.4.3 做 CI 冒烟。
- **方案(★ 已实现,2026-11-06 阶段 1-A9)**:①抽 `ScreenCaptureHost`/`DisplayCoordinateService` 接口并入 HostBridge 注入面(弹窗/DPI×2/AI 配置 4 方法 → 增至 6 方法,或独立 host services 包——实现期定,倾向并入 HostBridge 减少 API 面);vision/cv-vision 只依赖接口,宿主未注入时抛 `host_service_unavailable`;②check-deps 升级为覆盖 `import()`/`require()`/`createRequire()`(先补正则,中期 AST);③补齐 3 个依赖声明(pdf.js 与 canvas 为原生/大件,评估 optionalDependencies + 能力级降级);④消费方式锁定 **Bun 源码消费**(对面全家 workspace:* 源码依赖,与现状一致),锁 bun@1.4.3 做 CI 冒烟。
- **验收**:无可解析 electron 的纯净 Node/Bun 环境 Core 全量 import 成功;视觉能力未注入 Host 时返回 host_service_unavailable、注入 Fake Host 后可测;隔离目录仅按包声明装依赖跑通 PDF/OCR 代表能力。
### A10 插件生命周期、异常隔离与资源清理
@@ -281,10 +290,10 @@
| 阶段 | 内容 | 前置 | 验收门 |
|---|---|---|---|
| **0 事实确认**(双侧,1 次评审会) | 评审 R2(带方案 A/B 对比与三项待答问题)+ 3 项 Q;对面确认 CapabilityDefinition 平台字段、utilityExtended 对应 scope;同步已拍板 5 项结论(R1/R3/R4/R5/R6)给对面并预估 C# actuate 扩展量 | 无 | R2 与 Q 有书面结论 |
| **1 RPA 侧收口**(本仓库) | A3 四件(取消贯通/状态映射/重试元数据/Graph Validator)+ A1 注入参数 + A9(Electron 残留、check-deps 升级、依赖闭包)+ A2 的 ADR 固化 + catalog 脏值清洗 + rpa-cli 重写(显式注册、经 engine 取 handler) | 无 | 审查报告 §7 第一/二阶段清单全绿;纯 Node 环境 Core 全量加载 |
| **2 迁入与 adapter 骨架** | git subtree 迁包改名 @agent-worker/rpa-core;bun@1.4.3 test 冒烟(exceljs/pdf/PowerShell 编码);新建 @agent-worker/rpa-adapter(CapabilityRunner + ref 映射 + 状态映射表);demo 侧冻结 | 阶段 1 + ADR-R1/R2 | bun test 纯逻辑全绿;未经 gateway 不可达 handler;719 映射完整性测试 |
| **3 垂直切片** | 一个文件能力 + 一个 HTTP 能力走通:workflow-engine create/start → gateway authorize → adapter → rpa-core handler → 事件/证据回传 → cancel 中途取消;方案 A(rpa.flow.execute 整体注册) | 阶段 2 | 取消/审批/审计全链有证据;对面 Run 终态与 RunResult 一致 |
| **0 事实确认**(双侧,1 次评审会) | 向对面同步 6 项已拍板结论(R1-R6)并预估 C# actuate 扩展量;确认 2 个小问题(Q1 platform 字段 / Q2 utilityExtended 权限归属);确认事件流接口与 rpa.browser 清单备案 | 无 | Q 有书面结论 |
| **1 RPA 侧收口**(本仓库)✅ **已完成(2026-11-06,提交 9b76dc6/1da0082/1a20dd7/5e8fc96)** | A3 四件(取消贯通/状态映射/重试元数据/Graph Validator)+ A1 注入参数 + A9(Electron 残留、check-deps 升级、依赖闭包)+ A2 的 ADR 固化 + catalog 脏值清洗 + rpa-cli 重写(显式注册、经 engine 取 handler) | 无 | 审查报告 §7 第一/二阶段清单全绿;纯 Node 环境 Core 全量加载 |
| **2 迁入与 adapter 骨架** | git subtree 迁包改名 @agent-worker/rpa-core;bun@1.4.3 test 冒烟(exceljs/pdf/PowerShell 编码);新建 @agent-worker/rpa-adapter(CapabilityRunner + ref 映射 + 状态映射表);A++ 事件负载字段设计定稿(nodeId/检查点/幂等键,见 §7-A5);demo 侧冻结 | 阶段 1 + ADR-R1/R2 | bun test 纯逻辑全绿;未经 gateway 不可达 handler;719 映射完整性测试 |
| **3 垂直切片** | 一个文件能力 + 一个 HTTP 能力走通:workflow-engine create/start → gateway authorize → adapter → rpa-core handler → 事件/证据回传 → cancel 中途取消;方案 A(rpa.flow.execute 整体注册)+ A++ 检查点续跑最小验证(崩溃→resumeToken 重入,见 §7-A5) | 阶段 2 | 取消/审批/审计全链有证据;对面 Run 终态与 RunResult 一致 |
| **4 浏览器与桌面双 ADR 落地** | ADR-R4(已拍板):RPA 浏览器能力修完 transport/resolver,接对面白名单与证据体系后作为 RPA 场景主力 runner;ADR-R3:windows-runner C# 扩展 actuate(RPA 五域逻辑移植) | 阶段 3 | §7-A6/A7 验收条目 |
| **5 能力规模化** | 34 域按风险分批:files/data/http/ftp → office/excel/word(COM 仅 win32)→ desktop/utility 高影响(末批+审批);CapabilityDefinition 与权限映射随批生成 | 阶段 4 | 每批过 gateway 全量冒烟 + 审批矩阵抽查 |
| **6 平台扩展** | V1 第三节三端计划原样生效(Linux/macOS 驱动按 ADR-R3 原生通道) | 阶段 5 | 三平台 CI 矩阵;产品按 platformTag 置灰 |
@@ -304,9 +313,9 @@
| R3 | Registry 全局单例在多租户 backend 的隔离缺陷 | 高 | 短期按 ADR-R1 限定部署模型;长期 engine-scoped registry 列入 backlog |
| R4 | 浏览器双路径重复投资 | 低(已缓解) | R4 已拍板按场景分工(RPA=CDP 接管,AI 智能体=Playwright),白名单与证据共用一套,无重复投资 |
| R5 | 719 能力的治理映射与元数据补齐工作量失控 | 中 | 脚本从 catalog 生成初稿 + 人工只审高风险域;分批(阶段 5) |
| R6 | Bun 与 Node 行为差异(exceljs/pdf-lib/PowerShell 编码) | 中 | 阶段 2 bun 冒烟前置,差异清单化 |
| R6 | Bun 与 Node 行为差异(exceljs/pdf-lib/PowerShell 编码) | 中(部分缓解) | exceljs/pdf 已于 2026-11-06 本机 bun 1.4.4 冒烟通过(37/37);PowerShell 编码差异留待阶段 2 迁包后清单化 |
| R7 | demo 侧与 backend 双活跃漂移 | 中 | git subtree + "迁移后 demo 冻结"纪律(V1 决策 3) |
| R8 | 两套状态机语义漂移(NodeState vs StepStatus) | 中 | 映射表 + 契约测试常驻 CI |
| R8 | 两套状态机语义漂移(NodeState vs StepStatus) | 中 | 映射表 + 契约测试常驻 CI;A++ 检查点协议(ADR-R2 补记)统一崩溃恢复语义 |
| R9 | catalog 源数据(shizai)上游已删,重生成受阻 | 低 | 已从对面历史恢复至 temp/catalog-source;计划快照入库 |
| R10 | 对面底座继续快速演进,本文行号/接口过期 | 中 | 本文标注核查日期;联调前对关键接口做增量 diff |
@@ -332,22 +341,61 @@
| ADR | 问题 | 决定 |
|---|---|---|
| R1 | Registry 作用域:backend 是否多租户并存 | **全局一份能力清单**(菜单所有租户共用),权限隔离完全由 capability-gateway 按租户管;长期将清单挂到 engine 实例(已从对面源码确认 WorkflowIdentity 带 tenantId,但清单本身无需按租户复制) |
| R3 | 桌面执行安全模型:RPA PowerShell 驱动 vs 对面"受信 C# 原生进程"红线 | **C# 扩展 actuate**:把 RPA 五域执行逻辑移植进 windows-runner 原生进程;PowerShell 通道仅限本 demo,不进宿主 |
| R3 | 桌面执行安全模型:RPA PowerShell 驱动 vs 对面"受信 C# 原生进程"红线 | **C# 扩展 actuate**:把 RPA 五域执行逻辑移植进 windows-runner 原生进程;PowerShell 通道仅限本 demo,不进宿主。**原型已产出并编译验证(2026-11-06)**:`contrib/windows-runner-actuate/` 补丁包(协议扩展 + NativeInput + ActuateService + 逐原语移植对照表 + 合入指引含门禁合规清单);对面 csproj 原样合编 0 警告 0 错误(.NET 10.0.403),协议冒烟 5 例通过(安全门错误码/双路分发/observe 无回归);对面 README Windows 行"click/type 或随本轮交付"与该路线吻合 |
| R4 | 浏览器双路径 | **按场景分工**:RPA 场景走咱们的 CDP 接管路线(复用用户登录态/真实页面——RPA 核心价值);对面 PlaywrightBrowserRunner 留给 AI 智能体场景(无头自管浏览器)。约束:①咱们 CDP 路线必须接对面 BrowserContextPolicy 白名单与证据体系,不自建;②迁移前修完 transport 超时与定位器不统一两缺陷;③需对面在插件清单给咱们的 runner 备案(rpa.browser) |
| R5 | 包消费方式 | **源码并入 + 锁版本**:git subtree 进对面仓库统一编译,Bun 锁 1.4.3(与对面全家 workspace:* 源码消费现状一致) |
| R6 | 部署拓扑:rpa-core 各域跑在哪 | **按能力域拆分部署**:纯 Node 域→backend plugin-worker;桌面/视觉/弹窗域→用户电脑 Electron 宿主;同一包按 PLATFORM_GROUPS 分组加载,不维护两份代码 |
| R6 | 部署拓扑:rpa-core 各域跑在哪 | **按能力域拆分部署**:纯 Node 域→backend plugin-worker;桌面/视觉/弹窗域→用户电脑 Electron 宿主;同一包按 PLATFORM_GROUPS 分组加载,不维护两份代码。**补记(2026-11-06 用户拍板:双端并存+透明调度)**——同一能力本地/后端两驻地并存,SaaS 与本地部署两形态通用,用户无感知;防乱设计见下方专条 |
| R2 | 集成形态:RPA 流程=单个 capability(方案 A)还是节点=step 映射(方案 B) | **方案 A + 两补丁**:RPA 流程=`rpa.flow.execute` 单能力,对面一个 step 一次租约一次结算;节点级事件走 event-bus 旁路(保透明、不进权威账本);人审/高危节点照常升级对面审批。**不用 B 的理由**(记录在案):双账本一致性(崩溃窗口两边分歧、破坏对面 recovery 的单账本假设)、控制流节点无副作用无法映射 CapabilityRef、审批/重试粒度与业务语义错位。**B 的触发条件**:对面产品确认需要"逐步控制 RPA"(单步执行/任意步回退)时局部启用,事件流已备好数据不返工。**A++ 补记(2026-11-06 二次拍板)**:方案 A 骨架不变,节点事件流从"透明度日志"升级为**检查点协议**(nodeId+状态+幂等键+可恢复标记),崩溃后 `rpa.flow.execute` 带 resumeToken 重入、从最近检查点续跑,权威账本仍为单 step、无双账本;B 的隐藏触发条件(长流程断点续跑,RPA 桌面场景高发)由此覆盖而无需节点=step。业界对照:UiPath Orchestrator=Job 黑盒+日志旁路(≈A);Camunda/Zeebe External Task 的"单引擎假设"不适用于双引擎集成;AWS Step Functions callback≈补丁 2。两侧改动清单与落地节奏见 §7-A5。**租约粒度三次拍板(2026-11-06:每个节点执行都要慎重)**:外层单 step 不变,内层**每节点执行前单独 authorize+execute**(60s 租约/节点),高危节点逐节点审批——节点级治理粒度 + step 级账本粒度两全,实现方式见 §7-A4 |
| V1 | 能力版本管理 | **adapter 层承接对面硬约束(2026-11-06 设计定稿)**:对面三处硬约束(调用必带 capabilityVersion 且不符即拒 / 幂等键含版本 / manifest 强制三段 semver);我方 catalog 997 项带四段版本但注册表无版本机制。定案:对外能力 version=rpa-core 包 semver(仅对外行为变化才升版);catalog 四段版本归一化三段;configVersion=catalog+overrides 内容哈希。详见 §7-A4;对迁包零影响 |
### 待对面对评审确认(不猜测)
| ADR | 问题 | 评审要点 |
|---|---|---|
| R2 | 集成形态:RPA 流程=单个 capability(方案 A)还是节点=step 映射(方案 B)? | 本方需对面补充信息后定:①对面产品上是否要求逐步审批/逐步可见(决定 A 是否够用);②对面 workflow-engine 的 step 粒度是否有上限或性能顾虑(719 能力×节点数映射成 step 的量级);③对面对 RPA 执行中途取消/暂停的交互预期。方案对比:A=对面一个 step 跑完一条 RPA 流程(工作量小、状态映射简单、对面只见"开始/结束"+事件流);B=每个节点一个 step(全程透明可逐步审批,工作量数倍、两套状态机对表复杂)。本方倾向 A 起步、协议预留节点级事件上报。 |
### 待对面对确认(仅剩小问题,不猜测)
| Q# | 问题 | 评审要点 |
|---|---|---|
| Q1 | CapabilityDefinition 是否需要 platform 字段(对面清单未见) | 建议对面补字段或约定由 catalog platformTag 承载 |
| Q2 | utilityExtended(命令执行)对应哪个 PermissionScope | 13 项中 desktop.process 是候选,需对面确认语义(backend 无桌面,进程执行属哪域) |
| Q3 | human-dialog 弹窗宿主落点 | 已被 R6 顺带回答:用户电脑 Electron 宿主窗口;仅弹窗样式/交互细节与对面 UI 对齐 |
| Q1 | CapabilityDefinition 是否需要 platform 字段(对面清单未见) | **本方设计提案(2026-11-06)**:对面 plugin-contract 增可选 `platforms?: readonly ("win32"\|"darwin"\|"linux")[]`,缺省=全平台(旧清单零破坏);registry 的 `assertPluginManifest` 校验值域;plugin-worker 认领动作时按宿主 `process.platform` 过滤。RPA 侧清单由 rpa-adapter 从 catalog `platformTag` 自动生成,不手工维护第二份。与对面确认后照此实现 |
| Q2 | utilityExtended(命令执行)对应哪个 PermissionScope | **本方设计提案(2026-11-06)**:utilityExtended 域在对面源码无同名物,属纯 RPA 侧概念。提案映射到 `desktop.process`——RPA 命令执行发生在用户电脑 Electron 宿主内,宿主即桌面进程,语义吻合;该域标 highImpact + idempotencyRequired 走审批。若对面认为 desktop.process 严格指"桌面进程管理",备选新增 `desktop.command` scope。13 项 PermissionScope 其余映射维持 §7-A4 初稿 |
| Q4 | 高危审批的"上游服务"成熟度 | gateway 只按 actionId 精确放行,注释明言"审批有效性判定(是否过期、是否本人审批)由上游负责"(capability-gateway index.ts:13-14),但该上游服务的实现与闭环未见。RPA 人审弹窗方案(human-dialog→requestApproval→绑定 actionId)依赖它,须确认其签发/过期/防代批机制现状。**源码核查(2026-11-06):上游已实现非设计**——即服务端 workflow-engine 包:approval-policy 纯函数(actionId=runId:stepId:attempt 重试不被旧审批放行 / expiresAt 过期判定 / 仅 high 风险可发起)+ workflow-api 路由(request_approval / decide_approval,403 发起人不能审批自己=防代批、409 缺审批、410 已过期)+ Postgres 持久化;桥=plugin-worker 取 approvedActionIds 喂 gateway。缺口:审批人身份仍为开发态 token(PRODUCT_APPROVER_TOKEN,生产形态未接完);RPA 挂接的对齐点见下方"审批对象三层"专条 |
| Q5 | 租户权限配置面 | 每租户/员工的 `allowedPermissions` 白名单由谁配置、走哪条链路(entitlement-contract / identity-store 成熟度),adapter 对接前须核一眼 |
> 已回答:Q3(human-dialog 弹窗落点)已由 R6 回答——用户电脑 Electron 宿主窗口;仅样式/交互细节与对面 UI 对齐。
### 审批对象三层设计(2026-11-06 设计思考,待与上游对齐)
"审批什么、谁来批"须拆三层,混用会导致对面防代批机制(403)误伤合法场景:
| 层 | 对象 | 谁来批/定 | 时机与频率 | 载体 |
|---|---|---|---|---|
| ① 能力白名单 | 租户/员工能用哪类能力 | 管理员配置 | 事前一次,长期生效 |对面 Q5 链路(entitlement),**不产生审批单** |
| ② 流程上架 | 流程定义版本 + 它声明的能力清单 | 上游审批人 | 事前一次,多次运行复用 | 审批单(或管理端流程发布流程,待对齐用哪个) |
| ③ 单次高危动作 | 某次运行中的具体高危节点 | **分两种**:有人值守=用户知情同意;无人值守=上游审批人 | 事中,逐动作 | 有人值守=human-dialog 弹窗(consent,**不走审批单**,否则"自己批自己"被对面 403 挡死);无人值守=workflow-engine 审批单,运行暂停等批 |
推论(修正此前对齐清单):
- **actionId 粒度问题(attempt 维度)只存在于无人值守路径**;有人值守路径由"租约 60s + 每节点弹窗"兑现(§R2 三次拍板),与审批单无关。
- 日常有人值守运行**不需要**每次调 workflow-api;只有定时/远程触发的无人值守高危动作才走 request_approval 暂停等批——这正面回答了"发起方通道"问题。
- 对上游的对齐问题收敛为:②流程上架审批放哪个系统;③无人值守路径复用 workflow-engine 时 actionId(runId:stepId:attempt)与 rpa-core 幂等键(runId:nodeId)的映射(nodeId:attempt 是否补入 RunEvent);审批人身份生产化(trusted-gateway-headers / 管理端账号)。
### R6 补记:双端透明调度设计(2026-11-06 用户拍板:前端留、后端也留)
同一能力允许本地(用户电脑宿主)与后端(plugin-worker)两驻地并存,调度对用户无感知,SaaS 与本地部署两形态通用(本地部署时"后端"即客户自有机房,规则不变)。防乱靠三条纪律与一张确定性路由表:
**三条纪律:**
1. **一个脑决策**:路由只在宿主侧 Resolver 一处发生(本质是 A1 注入机制的扩展——registry 条目的 handler 绑定二选一:本地实现 or 自动生成的远端代理,远端代理走对面 gateway 授权/租约/审计,不绕行)。能力代码与流程定义里**禁止**出现任何驻地 if-else,规则全部来自 catalog 元数据。
2. **一份契约两处实现**:能力签名/参数/返回/错误码同一套(catalog 单一源已保证);本地与远端实现跑同一套契约测试;远端代理自动生成而非手写第二份逻辑,杜绝漂移。
3. **数据不动原则**:能力在哪个驻地执行由"数据已经在哪里"决定(代码就数据,不搬数据)。需要搬数据时必须是流程中的显式节点(上传/下载),**路由器不得隐式搬运用户本地文件到后端**——用户无感知的是"在哪算",不是"数据去哪"(隐私红线)。
**路由规则表(按序匹配,确定性,不留裁量空间):**
| 序 | 条件 | 执行驻地 |
|---|---|---|
| 1 | platformTag 限宿主平台 / 需要桌面会话 / 弹窗人审 | 仅本地(无远端选项) |
| 2 | 输入数据在本地(本地文件路径) | 本地 |
| 3 | 输入数据在云端(对面 tenant-files 等) | 后端 |
| 4 | 数据两驻地均有副本 | 默认本地(隐私+省带宽);流程可显式声明 `prefer: backend`(如夜间批量) |
| 5 | 本地不可用(宿主离线)且能力无本地数据依赖且标可远端 | 后端兜底 |
| 6 | 均不可用 | 节点失败,错误码 `location_unavailable` |
**用户无感知 ≠ 审计无感知**:`node.finished` 事件携带 `executor: "local" | "backend"` 字段(已定稿进 RunEvent 并有契约测试,当前恒为 `local`,远端代理落地后由代理覆盖)——用户界面不展示,审计与排障必须可分辨。该字段同时是对面 event-bus 入审计链的取用点。
---
+3
查看文件
@@ -15,7 +15,9 @@
"check:deps": "node scripts/check-deps.mjs"
},
"dependencies": {
"@cantoo/pdf-lib": "^2.11.1",
"@gutenye/ocr-node": "^1.4.9",
"@napi-rs/canvas": "^1.1.0",
"adm-zip": "^0.5.16",
"basic-ftp": "^6.2.3",
"exceljs": "^4.4.0",
@@ -25,6 +27,7 @@
"mysql2": "^3.24.6",
"nodemailer": "^6.9.16",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^4.10.38",
"pg": "^8.23.1",
"pngjs": "^7.0.0",
"sm-crypto": "^0.5.8",
+15
查看文件
@@ -18,6 +18,10 @@ const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..")
const srcRoot = path.join(pkgRoot, "src");
const IMPORT_RE = /(?:import|export)\s+(?:[\s\S]*?from\s*)?["']([^"']+)["']/g;
// 动态形式:import("x") / require("x") / createRequire 后的 require 调用。
// 旧正则要求 import 后跟空白,`import("electron")` 完全不匹配(实测 0 命中,
// vision/cv-vision 曾靠这个盲区逃过检查——迁移阶段1-A9 修复)。
const DYNAMIC_IMPORT_RE = /(?:\bimport\s*\(|\brequire\s*\()\s*["']([^"']+)["']/g;
async function walk(dir) {
const entries = await readdir(dir, { withFileTypes: true });
@@ -53,6 +57,17 @@ for (const file of await walk(srcRoot)) {
}
}
}
for (const match of source.matchAll(DYNAMIC_IMPORT_RE)) {
const spec = match[1];
const rel = path.relative(pkgRoot, file).replaceAll("\\", "/");
if (spec === "electron" || spec.startsWith("electron/")) {
offenders.push(`${rel}: 禁止动态 import("${spec}")(宿主能力走 HostBridge 注入)`);
continue;
}
if (spec.includes("src/main")) {
offenders.push(`${rel}: 禁止动态引用宿主模块 "${spec}"`);
}
}
}
if (offenders.length) {
+36 -17
查看文件
@@ -1,6 +1,7 @@
import { execFile, spawn, type ChildProcess } from "node:child_process";
import { promisify } from "node:util";
import { promises as fs } from "node:fs";
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { RuntimeError } from "../engine/types";
@@ -214,22 +215,26 @@ export async function connectRemoteBrowser(options: ExternalRemoteOptions): Prom
/** 清理占用专用 profile 的残留浏览器进程。
* 同一 user-data-dir 已有实例时,再次启动的 Chrome 会把请求转交旧实例并立即退出(代码 0),
* 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。 */
* 导致「启动失败(进程已退出,代码 0)」——这些实例都是本模块之前拉起的,清理是安全的。
* 匹配按完整 user-data-dir 路径字面量过滤(非通配),只会命中本模块自己拉起的实例。 */
async function killStaleBrowserProcesses(browserType: ExternalBrowserType, userDataDir: string): Promise<void> {
try {
if (process.platform === "win32") {
const names = browserType === "edge" ? "msedge.exe" : "chrome.exe";
const marker = userDataDir.replace(/'/g, "''");
// 单引号转义防注入;-like 用字面量路径(路径内无通配符语义字符时的精确匹配)
const marker = userDataDir.replace(/'/g, "''").replace(/`/g, "``").replace(/\[/g, "``[");
const script =
`Get-CimInstance Win32_Process -Filter "Name='${names}'" | ` +
`Where-Object { $_.CommandLine -like '*${marker}*' } | ` +
"ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }";
await execAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], { timeout: 8000, windowsHide: true });
} else {
// pkill -f 用完整 profile 路径字面量(含 rpa-studio-external 前缀),不扩大清理范围
await execAsync("pkill", ["-f", userDataDir], { timeout: 5000 });
}
} catch {
// 清理失败不阻塞启动:可能本来就没有残留,或权限受限
} catch (error) {
// 清理失败不阻塞启动:可能本来就没有残留,或权限受限;记 warning 供审计(整改:不再静默吞)
console.warn(`[browser] 清理残留 ${browserType} 进程失败(不影响启动):`, (error as Error).message);
}
}
@@ -247,7 +252,10 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
}
const userDataDir =
options.userDataDir?.trim() || path.join(os.tmpdir(), `rpa-studio-external-${browserType}`);
options.userDataDir?.trim() ||
// 并发/多会话隔离(审查整改):默认 profile 加入随机后缀,进程级唯一,
// 避免多个运行共用同一 profile 互相挤掉登录态与调试实例。
path.join(os.tmpdir(), `rpa-studio-external-${browserType}-${randomUUID().slice(0, 8)}`);
await fs.mkdir(userDataDir, { recursive: true });
const extraArgs = (options.extraArgs ?? []).filter(Boolean);
@@ -309,19 +317,30 @@ export async function launchExternalBrowser(options: ExternalLaunchOptions): Pro
});
}
// 就绪之后的接管链(/json/list → transport.connect):任一步失败都必须回收自己拉起的
// 子进程(审查整改:此前失败路径会遗留浏览器进程)。成功路径 killOnClose=true 交由关闭逻辑。
const base = `http://127.0.0.1:${port}`;
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
const target = pickPageTarget(targets, "about:blank");
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`);
return {
transport,
sessionKey: `launch|${browserType}|${port}`,
label: `${label} (127.0.0.1:${port})`,
httpBase: base,
targetId: String(target.id ?? ""),
child,
killOnClose: true
};
try {
const targets = await fetchJson<CdpTargetInfo[]>(`${base}/json/list`, 5000);
const target = pickPageTarget(targets, "about:blank");
const transport = await ExternalTransport.connect(target.webSocketDebuggerUrl as string, `127.0.0.1:${port}`);
return {
transport,
sessionKey: `launch|${browserType}|${port}`,
label: `${label} (127.0.0.1:${port})`,
httpBase: base,
targetId: String(target.id ?? ""),
child,
killOnClose: true
};
} catch (error) {
try {
child.kill();
} catch {
// 进程可能已自行退出
}
throw error;
}
}
/** 列出调试端点上的全部页面级 target(多标签枚举用);排除 chrome://、edge:// 内部页 */
+44 -8
查看文件
@@ -54,11 +54,16 @@ export class ExternalTransport implements CdpTransport {
private handlers = new Map<string, Set<CdpEventHandler>>();
private closedFlag = false;
private readonly openPromise: Promise<void>;
// 单命令超时:CDP 对端不回包时(页面冻结/debugger 忙/eval 挂起),send 必须在有限时间内失败,
// 否则挂起会占住节点直到引擎级超时,且 pending 表项泄漏。
private readonly commandTimeoutMs: number;
private constructor(
wsUrl: string,
readonly description: string
readonly description: string,
commandTimeoutMs = 30_000
) {
this.commandTimeoutMs = commandTimeoutMs;
this.openPromise = new Promise<void>((resolve, reject) => {
// perMessageDeflate 必须显式关闭:ws 客户端默认开启压缩协商后,若 ws 被打包进
// 主进程 bundle,其可选依赖 bufferutil 的解析产物会破坏 mask fallback,
@@ -80,13 +85,27 @@ export class ExternalTransport implements CdpTransport {
});
}
static async connect(wsUrl: string, description: string, timeoutMs = 10000): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description);
const timeout = new Promise<never>((_, reject) =>
setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs)
);
await Promise.race([transport.openPromise, timeout]);
return transport;
static async connect(
wsUrl: string,
description: string,
timeoutMs = 10000,
commandTimeoutMs = 30_000
): Promise<ExternalTransport> {
const transport = new ExternalTransport(wsUrl, description, commandTimeoutMs);
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new RuntimeError("browser_disconnected", `连接外部浏览器超时:${description}`)), timeoutMs);
});
try {
await Promise.race([transport.openPromise, timeout]);
return transport;
} catch (error) {
// 超时/失败路径必须关闭底层 WebSocket(审查整改:此前失败会泄漏挂起的 ws)
await transport.close().catch(() => {});
throw error;
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
get closed(): boolean {
@@ -101,6 +120,7 @@ export class ExternalTransport implements CdpTransport {
}
const id = this.nextId++;
const ws = this.ws as WebSocket;
let timeoutTimer: ReturnType<typeof setTimeout> | undefined;
const reply = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
try {
@@ -108,12 +128,28 @@ export class ExternalTransport implements CdpTransport {
} catch (error) {
this.pending.delete(id);
reject(error as Error);
return;
}
// 单命令超时:以稳定错误码失败并清理 pending,避免对端不回包时永久挂起
timeoutTimer = setTimeout(() => {
if (this.pending.delete(id)) {
reject(
new RuntimeError("timeout", `CDP 命令超时:${method}`, {
detail: `单命令超时上限 ${this.commandTimeoutMs}ms,对端未返回响应`,
recommendation: "检查目标页面是否冻结或弹出了模态对话框;必要时调大节点超时时间"
})
);
}
}, this.commandTimeoutMs);
});
try {
return (await reply) as T;
} catch (error) {
// RuntimeError(如上面的单命令超时)原样透传,不再经 mapCdpError 改写错误码
if (error instanceof RuntimeError) throw error;
throw mapCdpError(error, "external");
} finally {
if (timeoutTimer !== undefined) clearTimeout(timeoutTimer);
}
}
@@ -8,7 +8,7 @@ import { powershell, psString } from "../driver/windows/powershell";
import { defaultEngine } from "../engine/engine";
// 窗口/屏幕查询统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
const getDriver = () => defaultEngine.driver;
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) { const value = args[key]; if (value !== undefined && value !== null && value !== "") return String(value); }
@@ -24,21 +24,21 @@ register("Window_GetRect", delegate("Window_GetWindowRect"));
register("Window_MaximumSize", (ctx, node, args, component) => delegate("Window_SetWindowState")(ctx, node, { ...args, WindowType: "maximize" }, component));
register(["Keyboard_DriveSend", "Keyboard_DriveSend_V1", "Keyboard_DriveSend_V2"], delegate("Keyboard_Send_V1"));
register("Window_GetScreenSize", async () => driver.screen.size());
register("Window_GetScreenSize", async () => getDriver().screen.size());
register("Window_WindowList", async (_ctx, _node, args) => {
const title = text(args, ["match_title", "select_title"]); const regex = String(args.is_regex ?? "0") === "1";
return driver.window.list({ title, regex });
return getDriver().window.list({ title, regex });
});
register("Window_SetWindowShow", async (_ctx, node, args) => {
const title = text(args, ["窗口对象", "window_obj", "title"]); if (!title || title === "窗口对象") throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const show = !text(args, ["WindowType"], "show").toLowerCase().includes("hide");
await driver.window.setState(title, show ? 5 : 0);
await getDriver().window.setState(title, show ? 5 : 0);
return show;
});
register("Window_WaitWindow", async (_ctx, node, args) => {
const title = text(args, ["match_title", "select_title", "window_obj", "target_window"]); if (!title) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
const expected = text(args, ["switchType"], "0") !== "1"; const deadline = Date.now() + Math.max(0, Number(args.second ?? 5)) * 1000;
do { const exists = await driver.window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
do { const exists = await getDriver().window.exists(title); if (exists === expected) return true; await new Promise((resolve) => setTimeout(resolve, 200)); } while (Date.now() <= deadline);
return false;
});
+19 -24
查看文件
@@ -1,5 +1,6 @@
import { register } from "../engine/registry";
import { RuntimeError, type CapabilityHandler } from "../engine/types";
import { requireScreenService } from "../engine/host-bridge";
import { powershell } from "../driver/windows/powershell";
import fs from "node:fs";
import os from "node:os";
@@ -44,30 +45,27 @@ async function gdiGrabScreen(imagePath: string): Promise<void> {
/** 一次屏幕截图: 图片左上角在虚拟桌面上的绝对 DIP 坐标 + 物理/DIP 缩放比 */
interface ScreenShot { imagePath: string; dipX: number; dipY: number; scale: number }
/** desktopCapturer 逐显示器截屏(每屏原生分辨率), 不可用时回退 GDI 全虚拟桌面单图 */
/** 宿主屏幕服务逐显示器截屏(每屏原生分辨率), 不可用时回退 GDI 全虚拟桌面单图 */
async function grabScreenAll(baseDir: string): Promise<ScreenShot[]> {
fs.mkdirSync(baseDir, { recursive: true });
try {
const { desktopCapturer, screen } = await import("electron");
if (desktopCapturer && screen?.getAllDisplays) {
const shots: ScreenShot[] = [];
const displays = screen.getAllDisplays();
for (const display of displays) {
const scale = display.scaleFactor || 1;
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: Math.round(display.bounds.width * scale), height: Math.round(display.bounds.height * scale) }
});
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === Math.round(display.bounds.width * scale))
|| sources[0];
if (!source || source.thumbnail.isEmpty()) continue;
const screenHost = requireScreenService();
const shots: ScreenShot[] = [];
for (const display of screenHost.listDisplays()) {
const scale = display.scaleFactor || 1;
try {
const pngBytes = await screenHost.captureDisplay(
display.id,
Math.round(display.bounds.width * scale),
Math.round(display.bounds.height * scale)
);
if (!pngBytes?.length) continue;
const imagePath = path.join(baseDir, `rpa-cv-screen-${display.id}-${Date.now()}.png`);
fs.writeFileSync(imagePath, source.thumbnail.toPNG());
fs.writeFileSync(imagePath, Buffer.from(pngBytes));
shots.push({ imagePath, dipX: display.bounds.x, dipY: display.bounds.y, scale });
}
if (shots.length) return shots;
} catch {}
}
if (shots.length) return shots;
} catch {}
const imagePath = path.join(baseDir, `rpa-cv-screen-${Date.now()}.png`);
await gdiGrabScreen(imagePath);
@@ -207,14 +205,11 @@ function coordinateScript(x: number, y: number, action: "hover" | "click" | "dou
return lines.join("\n");
}
/** DIP → 物理像素(SetCursorPos 在 DPI 感知进程里用物理坐标); 无 Electron API 时按原值(缩放 100% 等价) */
/** DIP → 物理像素(SetCursorPos 在 DPI 感知进程里用物理坐标); 无宿主屏幕服务时按原值(缩放 100% 等价) */
async function toPhysical(x: number, y: number): Promise<{ x: number; y: number }> {
try {
const { screen } = await import("electron");
if (screen && typeof screen.dipToScreenPoint === "function") {
const p = screen.dipToScreenPoint({ x: Math.round(x), y: Math.round(y) });
return { x: p.x, y: p.y };
}
const point = requireScreenService().dipToScreenPoint({ x: Math.round(x), y: Math.round(y) });
return { x: point.x, y: point.y };
} catch {}
return { x: Math.round(x), y: Math.round(y) };
}
+12 -10
查看文件
@@ -6,7 +6,9 @@ import { powershell, psString } from "../driver/windows/powershell";
// 人为拟真设置本体挂在引擎实例上(任务 1.3),此处委托 defaultEngine。
const manualOperation: ManualOperationSettings = defaultEngine.manualOperation;
// 桌面自动化驱动(任务 3.3):user32 注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
// 惰性获取驱动(迁移阶段1-A1):非 win32 上 import 本模块不再抛错,
// 调用桌面能力时才触发 platform_unsupported;也使注入 driver 的引擎可被测试/宿主使用
const getDriver = () => defaultEngine.driver;
function jitter(value: number, amount = 3): number {
return Math.max(0, Math.round(value + (Math.random() * amount * 2 - amount)));
@@ -63,7 +65,7 @@ const keyboardSend: CapabilityHandler = async (_ctx, node, args) => {
const isTextSend = node.componentId === "Keyboard_Send" || node.componentId === "Keyboard_Send_V1";
// 指定目标窗口时先激活再输入,内容才会进到目标窗口而不是运行时的焦点窗口
const targetTitle = text(args, ["window_title", "target_window", "window_obj", "match_title", "select_title"]);
await driver.keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined });
await getDriver().keyboard.send({ keys, intervalMs, mode: mode === "2" ? "clipboard" : mode === "1" ? "manual" : "api", textMode: isTextSend, activateTitle: targetTitle || undefined });
await manualDelay();
return keys;
};
@@ -88,47 +90,47 @@ const disableManualOperation: CapabilityHandler = async () => {
const mouseClick: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), false, true);
const button = text(args, ["side", "button"], "0").toLowerCase();
await driver.mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" });
await getDriver().mouse.click({ x, y }, { button: button.includes("right") || button === "1" ? "right" : "left" });
return { x, y };
};
const mouseMove: CapabilityHandler = async (_ctx, node, args) => {
const [x, y] = manualJitter(point(args.position ?? args.point ?? args.location ?? "[0,0]"), true, false);
await driver.mouse.moveTo({ x, y });
await getDriver().mouse.moveTo({ x, y });
return { x, y };
};
const mouseDrag: CapabilityHandler = async (_ctx, node, args) => {
const [sx, sy] = point(args.Start ?? args.start ?? "[0,0]");
const [ex, ey] = point(args.End ?? args.end ?? "[0,0]");
await driver.mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 });
await getDriver().mouse.drag({ x: sx, y: sy }, { x: ex, y: ey }, { holdMs: 80, releaseDelayMs: 80 });
return { start: [sx, sy], end: [ex, ey] };
};
const mouseRoll: CapabilityHandler = async (_ctx, node, args) => {
const amount = number(args, ["RollNum", "rollNum", "amount"], 1) * 120;
await driver.mouse.scroll(amount);
await getDriver().mouse.scroll(amount);
return amount;
};
const setActiveWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return driver.window.focus(selector);
return getDriver().window.focus(selector);
};
const getActiveWindow: CapabilityHandler = async () => driver.window.foregroundTitle();
const getActiveWindow: CapabilityHandler = async () => getDriver().window.foregroundTitle();
const windowExists: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return driver.window.exists(selector);
return getDriver().window.exists(selector);
};
const closeWindow: CapabilityHandler = async (_ctx, node, args) => {
const selector = text(args, ["window_obj", "match_title", "select_title", "title", "target_window", "window_title"]);
if (!selector) throw new RuntimeError("validation_error", "窗口标题为空", { nodeId: node.id, componentId: node.componentId });
return driver.window.close(selector);
return getDriver().window.close(selector);
};
const runApp: CapabilityHandler = async (_ctx, node, args) => {
@@ -33,7 +33,7 @@ function selector(args: Record<string, unknown>): string {
}
// 窗口矩形/状态操作统一走驱动(任务 3.3)
const driver = defaultEngine.driver;
const getDriver = () => defaultEngine.driver;
register("System_GetEnv", (_ctx, node, args) => {
const name = required(args, ["name"], "环境变量名称", node.id, node.componentId);
@@ -104,14 +104,14 @@ register("Clipboard_ClearClipboard", async () => {
register("Window_GetWindowRect", async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
return driver.window.findByTitle(title);
return getDriver().window.findByTitle(title);
});
register(["Window_SetWindowState", "Window_SetWindowState_v1"], async (_ctx, node, args) => {
const title = required({ title: selector(args) }, ["title"], "窗口标题", node.id, node.componentId);
const state = value(args, ["WindowType", "switchType"], "maximize").toLowerCase();
const command = state.includes("min") ? 6 : state.includes("max") ? 3 : 9;
await driver.window.setState(title, command);
await getDriver().window.setState(title, command);
return state;
});
@@ -124,6 +124,6 @@ register("Window_SizeWindow_v1", async (_ctx, node, args) => {
}
const [width, height] = Array.isArray(size) ? size.map(Number) : [0, 0];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸无效", { nodeId: node.id, componentId: node.componentId });
await driver.window.setSize(title, width, height);
await getDriver().window.setSize(title, width, height);
return { width, height };
});
+13 -20
查看文件
@@ -3,6 +3,7 @@ import { promises as fs } from "node:fs";
import { PNG } from "pngjs";
import { register } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import { requireScreenService, type HostDisplay } from "../engine/host-bridge";
import { powershell, psString } from "../driver/windows/powershell";
function text(args: Record<string, unknown>, keys: string[], fallback = ""): string {
@@ -15,28 +16,20 @@ function text(args: Record<string, unknown>, keys: string[], fallback = ""): str
* 逐屏截图后按各自物理原点拼到一张画布上——目标在副屏也能搜到。
* 返回 originX/originY = 拼接画布左上角对应的屏幕物理坐标(副屏在主屏左侧时为负),用于结果坐标平移。 */
async function captureVirtualScreenPng(): Promise<{ png: PNG; originX: number; originY: number }> {
const { desktopCapturer, screen } = await import("electron");
const displays = screen.getAllDisplays();
const screenHost = requireScreenService();
const displays = screenHost.listDisplays();
if (displays.length === 0) throw new RuntimeError("desktop_error", "无法获取屏幕信息");
// 每屏按自己的物理分辨率截图(display_id 匹配 → 缩略图尺寸匹配 → 按物理原点排序对位)
const captures: { display: Electron.Display; origin: { x: number; y: number }; png: PNG }[] = [];
// 每屏按自己的物理分辨率截图(宿主按 displayId 对位 → 按物理原点排序对位)
const captures: { display: HostDisplay; origin: { x: number; y: number }; png: PNG }[] = [];
for (const display of displays) {
const scale = display.scaleFactor || 1;
const wantW = Math.round(display.bounds.width * scale);
const wantH = Math.round(display.bounds.height * scale);
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: wantW, height: wantH }
});
const source = sources.find(item => item.display_id === String(display.id))
|| sources.find(item => item.thumbnail.getSize().width === wantW)
|| sources[0];
if (!source || source.thumbnail.isEmpty()) {
throw new RuntimeError("desktop_error", "无法获取屏幕截图");
}
const pngBytes = await screenHost.captureDisplay(display.id, wantW, wantH);
if (!pngBytes?.length) throw new RuntimeError("desktop_error", "无法获取屏幕截图");
// 显示器 DIP 原点的物理坐标 = 该屏物理原点(屏内线性映射: 物理 = (DIP-原点)*缩放 + 物理原点)
const origin = screen.dipToScreenPoint({ x: display.bounds.x, y: display.bounds.y });
captures.push({ display, origin, png: PNG.sync.read(source.thumbnail.toPNG()) });
const origin = screenHost.dipToScreenPoint({ x: display.bounds.x, y: display.bounds.y });
captures.push({ display, origin, png: PNG.sync.read(Buffer.from(pngBytes)) });
}
const originX = Math.min(...captures.map(c => c.origin.x));
const originY = Math.min(...captures.map(c => c.origin.y));
@@ -63,9 +56,9 @@ async function captureVirtualScreenPng(): Promise<{ png: PNG; originX: number; o
/** DIP 区域 → 物理像素区域:左上角经 dipToScreenPoint 换算(天然支持副屏/负坐标),宽高乘所在屏缩放比 */
async function dipRegionToPhysical(region: { x: number; y: number; width: number; height: number }): Promise<{ x: number; y: number; width: number; height: number }> {
const { screen } = await import("electron");
const topLeft = screen.dipToScreenPoint({ x: Math.round(region.x), y: Math.round(region.y) });
const display = screen.getDisplayMatching({
const screenHost = requireScreenService();
const topLeft = screenHost.dipToScreenPoint({ x: Math.round(region.x), y: Math.round(region.y) });
const display = screenHost.displayMatching({
x: region.x, y: region.y,
width: Math.max(1, Math.round(region.width)), height: Math.max(1, Math.round(region.height))
});
@@ -429,7 +422,7 @@ async function locate(args: Record<string, unknown>, evidenceDir: string, nodeId
const rawAccuracy = Number(text(args, ["accuracy"], "0.8"));
const similarity = Math.max(0.05, Math.min(1, Number.isFinite(rawAccuracy) && rawAccuracy > 0 ? rawAccuracy : 0.8));
// 原生 1:1 匹配失败时, 按缩放比对模板放大/缩小重试——兼容旧版 DIP 截图存的模板与任意来源的图片
const scale = (await import("electron")).screen.getPrimaryDisplay().scaleFactor || 1;
const scale = requireScreenService().primaryDisplay().scaleFactor || 1;
const result = await findImage(template, screenshot, 1 - similarity, scale !== 1 ? [scale, 1 / scale] : []);
if (!result) throw new RuntimeError("not_found", `屏幕中未找到目标图像(要求相似度 ${(similarity * 100).toFixed(0)}%,未达到阈值)`, { nodeId, recommendation: "重新框选模板(框小一点、只框目标图案),或适当调低相似度(如 0.8);目标在副屏时确认其清晰可见" });
return { ...result, x: result.x + offsetX, y: result.y + offsetY };
@@ -4,7 +4,7 @@ import { getHostBridge } from "../engine/host-bridge";
import { defaultEngine } from "../engine/engine";
// 桌面自动化驱动(任务 3.3):窗口矩形/鼠标注入原语统一走 WindowsDriver
const driver = defaultEngine.driver;
const getDriver = () => defaultEngine.driver;
function value(args: Record<string, unknown>, keys: string[], fallback = ""): string {
for (const key of keys) {
@@ -77,17 +77,17 @@ function physicalToDip(x: number, y: number): [number, number] {
return [Math.round(x), Math.round(y)];
}
const getWindow: CapabilityHandler = async (_ctx, node, args) => driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
const getWindow: CapabilityHandler = async (_ctx, node, args) => getDriver().window.findByTitle(requiredTitle(args, node.id, node.componentId));
register(["Window_Get", "Window_GetWindow", "Window_GetWindow_v1"], getWindow);
register("Window_ActiveWindow_v1", async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const maximize = [true, 1, "1", "true", "True"].includes(args.isMax as never);
return driver.window.activate(title, { maximize });
return getDriver().window.activate(title, { maximize });
});
register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx, node, args) => {
const object = await driver.window.findByTitle(requiredTitle(args, node.id, node.componentId));
const object = await getDriver().window.findByTitle(requiredTitle(args, node.id, node.componentId));
const type = value(args, ["switchType"], "all").toLowerCase();
if (type === "title") return object.title;
if (type === "pid") return object.pid;
@@ -98,7 +98,7 @@ register(["Window_GetWindowMessage", "Window_GetWindowMessage_v1"], async (_ctx,
register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_SizeWindow"], async (_ctx, node, args) => {
const title = requiredTitle(args, node.id, node.componentId);
const current = await driver.window.findByTitle(title);
const current = await getDriver().window.findByTitle(title);
const moveOnly = node.componentId.includes("MoveWindow");
const sizeOnly = node.componentId === "Window_SizeWindow";
const position = args.location !== undefined ? parsePoint(args.location, "窗口位置", node.id, node.componentId) : [Number(args.x ?? current.x), Number(args.y ?? current.y)] as [number, number];
@@ -109,7 +109,7 @@ register(["Window_SetPos", "Window_MoveWindow", "Window_MoveWindow_v1", "Window_
const width = moveOnly ? Number(current.width) : size[0];
const height = moveOnly ? Number(current.height) : size[1];
if (!(width > 0 && height > 0)) throw new RuntimeError("validation_error", "窗口尺寸必须大于 0", { nodeId: node.id, componentId: node.componentId });
return driver.window.setPos(title, { x, y, width, height });
return getDriver().window.setPos(title, { x, y, width, height });
});
const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
@@ -122,7 +122,7 @@ const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
let dipTarget: [number, number];
if (relative) {
// 相对激活窗口左上角:窗口矩形是物理像素,换回 DIP 再叠加偏移,最后统一转物理
const { x: wx, y: wy } = await driver.window.foregroundPosition();
const { x: wx, y: wy } = await getDriver().window.foregroundPosition();
if (!Number.isFinite(wx) || !Number.isFinite(wy)) throw new RuntimeError("browser_error", "无法获取激活窗口位置", { nodeId: node.id, componentId: node.componentId });
const [dipWx, dipWy] = physicalToDip(wx, wy);
dipTarget = [dipWx + rawX, dipWy + rawY];
@@ -131,10 +131,10 @@ const moveMouse: CapabilityHandler = async (_ctx, node, args) => {
}
const [tx, ty] = dipToPhysical(dipTarget[0], dipTarget[1]);
if (moveType === "2") {
await driver.mouse.moveTo({ x: tx, y: ty });
await getDriver().mouse.moveTo({ x: tx, y: ty });
} else {
// 平滑移动:从当前光标位置出发,按速度插值(路径全程物理像素,与目标同一坐标系)
await driver.mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed });
await getDriver().mouse.moveSmooth({ x: tx, y: ty }, { ease: moveType === "0", speed });
}
return { x: Math.round(dipTarget[0]), y: Math.round(dipTarget[1]) };
};
@@ -147,7 +147,7 @@ const dragMouse: CapabilityHandler = async (_ctx, node, args) => {
const [epx, epy] = dipToPhysical(ex, ey);
const right = ["1", "right"].includes(value(args, ["side"], "0").toLowerCase());
// 旧实现按下后停 80ms、拖到终点立即松开(与桌面版 Window_Mouse 不同,松开前不停顿)
await driver.mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 });
await getDriver().mouse.drag({ x: spx, y: spy }, { x: epx, y: epy }, { button: right ? "right" : "left", holdMs: 80, releaseDelayMs: 0 });
return { start: [Math.round(sx), Math.round(sy)], end: [Math.round(ex), Math.round(ey)] };
};
register(["Mouse_DragToV1", "Mouse_DragToV2", "Mouse_DragToNew"], dragMouse);
@@ -159,6 +159,6 @@ register(["Mouse_ClickCombine", "Mouse_ClickCombine_v1", "Mouse_ClickCombine_v2"
const right = value(args, ["click_key"], "left").toLowerCase() === "right";
const doubleClick = value(args, ["click_time"], "click").toLowerCase().includes("double");
// 旧实现每次 down-up 之后停 80ms
await driver.mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 });
await getDriver().mouse.click({ x: px, y: py }, { button: right ? "right" : "left", double: doubleClick, settleMs: 80 });
return { x: Math.round(x), y: Math.round(y), button: right ? "right" : "left", clicks: doubleClick ? 2 : 1 };
});
+29 -1
查看文件
@@ -1,4 +1,5 @@
import path from "node:path";
import { access, mkdir } from "node:fs/promises";
import { register } from "../engine/registry";
import { powershell, psString } from "../driver/windows/powershell";
@@ -41,4 +42,31 @@ register("Word_Write", async (_ctx, _node, args) => {
return { path: filePath } satisfies WordHandle;
});
register(["Word_Save", "Word_SaveAs"], async (_ctx, _node, args) => ({ path: handlePath(args) } satisfies WordHandle));
async function ensureFile(value: string): Promise<string> {
try {
await access(value);
} catch {
throw new Error(`Word文件不存在: ${value}`);
}
return value;
}
register("Word_Save", async (_ctx, _node, args) => {
const filePath = await ensureFile(ensure(handlePath(args), "Word文件路径"));
// 打开即存:架构上每次操作独立开合 Word 实例,Save 负责兜底刷新并证明文件真实可写
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(filePath)}); $doc.Save()`));
return { path: filePath } satisfies WordHandle;
});
register("Word_SaveAs", async (_ctx, _node, args) => {
const previousPath = await ensureFile(ensure(handlePath(args), "Word文件路径"));
const rawDir = text(args, ["fileAddr", "path", "folder", "savePath"]);
const rawName = text(args, ["filename", "name"]);
// 目录空缺沿用原目录;目录相对时基于原文档目录解析;文件名空缺沿用原名(与 catalog 语义一致)
const dir = rawDir ? path.resolve(path.dirname(previousPath), rawDir) : path.dirname(previousPath);
const target = path.resolve(dir, rawName || path.basename(previousPath));
const withExt = path.extname(target) ? target : `${target}.docx`;
await mkdir(path.dirname(withExt), { recursive: true });
await powershell(wordScript(`$doc = $word.Documents.Open(${psString(previousPath)}); $doc.SaveAs2(${psString(withExt)}); ${psString(withExt)}`));
return { path: withExt } satisfies WordHandle;
});
@@ -1,22 +0,0 @@
import { describe, expect, it } from "vitest";
import { createEngine } from "./engine";
import { WindowsDriver } from "../driver/windows/windows-driver";
describe("driver 平台自动选择(迁移指南五件小事 #2)", () => {
it("win32 上惰性创建 WindowsDriver 且实例缓存", () => {
const engine = createEngine();
expect(engine.driver).toBeInstanceOf(WindowsDriver);
expect(engine.driver).toBe(engine.driver);
});
it("非 win32 访问 driver 抛 platform_unsupported", () => {
const engine = createEngine();
const original = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
expect(() => engine.driver).toThrowError(/暂不支持桌面自动化驱动/);
} finally {
Object.defineProperty(process, "platform", { value: original, configurable: true });
}
});
});
+35 -3
查看文件
@@ -2,7 +2,9 @@ import type { Client } from "basic-ftp";
import type { Workbook as ExcelJSWorkbook } from "exceljs";
import { WindowsDriver } from "../driver/windows/windows-driver";
import type { AutomationDriver } from "../driver/types";
import { RuntimeError } from "./types";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "./registry";
import { RuntimeError, type RunContext } from "./types";
/** 人为拟真操作的开关与随机参数(desktop 能力的进程级设置)。 */
export interface ManualOperationSettings {
@@ -59,10 +61,40 @@ export class RpaEngine {
}
return this._driver;
}
/** 注入驱动实现(测试替身或宿主适配器);注入后不再走平台自动选择 */
setDriver(driver: AutomationDriver): void {
this._driver = driver;
}
/**
* 单能力直接调用(ADR-R1 语义真实化):CLI/宿主脚本不再裸摸全局 getHandler,
* 而是经引擎入口调用。注意 handler 仍解析自进程注册表(ADR-R1 短期形态)。
*/
async execute(
componentId: string,
args: Record<string, unknown>,
ctx: RunContext,
node?: FlowNode,
component?: CatalogComponent
): Promise<unknown> {
const handler = getHandler(componentId);
if (!handler) {
throw new RuntimeError("capability_not_implemented", `能力 ${componentId} 未注册`, { componentId });
}
const resolvedNode: FlowNode = node ?? { id: "direct", componentId, position: { x: 0, y: 0 }, values: {}, options: {} };
return handler(ctx, resolvedNode, args, component as CatalogComponent);
}
}
export function createEngine(): RpaEngine {
return new RpaEngine();
export interface EngineOptions {
/** 注入桌面自动化驱动(测试替身或宿主适配器);缺省按 process.platform 自动选择 */
driver?: AutomationDriver;
}
export function createEngine(options?: EngineOptions): RpaEngine {
const engine = new RpaEngine();
if (options?.driver) engine.setDriver(options.driver);
return engine;
}
/** 能力模块内部使用的默认引擎实例(与全局注册表配套的进程级单例)。 */
@@ -0,0 +1,117 @@
import type { FlowDefinition } from "../types";
/**
* 流程图静态校验(任务:审查报告 P0-03)。
* 在执行前拦截结构性错误,避免运行期以诡异方式失败。
* 只做纯结构检查,不读目录/注册表(组件是否存在由运行时校验并给出友好错误)。
*/
export interface FlowIssue {
severity: "error" | "warning";
code: string;
nodeId?: string;
edgeId?: string;
message: string;
}
const CONTROL_FLOW_COMPONENTS = new Set([
"logic_control_if_else",
"logic_control_count_for",
"logic_control_array_for_v3",
"logic_control_dictionary_for_v1",
"logic_control_while",
"infinite_loop",
"logic_control_try_catch"
]);
export function validateFlowGraph(flow: FlowDefinition): FlowIssue[] {
const issues: FlowIssue[] = [];
const nodeIds = new Set<string>();
for (const node of flow.nodes) {
if (nodeIds.has(node.id)) {
issues.push({ severity: "error", code: "duplicate_node_id", nodeId: node.id, message: `节点 ID 重复:${node.id}` });
}
nodeIds.add(node.id);
if (node.parentId && !flow.nodes.some((candidate) => candidate.id === node.parentId)) {
issues.push({
severity: "error",
code: "orphan_child",
nodeId: node.id,
message: `节点「${node.label || node.id}」挂在不存在的父节点 ${node.parentId} 下`
});
}
}
for (const edge of flow.edges) {
if (!nodeIds.has(edge.source)) {
issues.push({ severity: "error", code: "dangling_edge", edgeId: edge.id, message: `连线 ${edge.id} 的起点 ${edge.source} 不存在于画布` });
}
if (!nodeIds.has(edge.target)) {
issues.push({ severity: "error", code: "dangling_edge", edgeId: edge.id, message: `连线 ${edge.id} 的终点 ${edge.target} 不存在于画布` });
}
if (edge.source === edge.target && nodeIds.has(edge.source)) {
issues.push({
severity: "error",
code: "self_loop",
edgeId: edge.id,
nodeId: edge.source,
message: `节点「${edge.source}」连接到自己,执行时会立即跳过`
});
}
}
// 顶层图环检测:执行引擎从起点沿边游走且不重访,环意味着存在永远不会执行的分支或意图不明的回边
const top = flow.nodes.filter((node) => !node.parentId);
const topIds = new Set(top.map((node) => node.id));
const edges = flow.edges.filter((edge) => topIds.has(edge.source) && topIds.has(edge.target));
const adjacency = new Map<string, string[]>();
for (const edge of edges) {
if (edge.source === edge.target) continue;
adjacency.set(edge.source, [...(adjacency.get(edge.source) || []), edge.target]);
}
const incoming = new Set(edges.map((edge) => edge.target));
const start =
top.find((node) => !incoming.has(node.id) && node.componentId.startsWith("Trigger.")) ||
top.find((node) => !incoming.has(node.id)) ||
top[0];
if (start) {
const state = new Map<string, "visiting" | "done">();
const reachable = new Set<string>();
const visit = (nodeId: string, path: string[]): void => {
if (state.get(nodeId) === "done") return;
if (state.get(nodeId) === "visiting") {
const cycleStart = path.indexOf(nodeId);
const cycle = [...path.slice(cycleStart), nodeId].join(" → ");
issues.push({ severity: "error", code: "cycle", nodeId, message: `顶层流程存在环,环上的节点执行顺序不明确:${cycle}` });
return;
}
state.set(nodeId, "visiting");
reachable.add(nodeId);
for (const next of adjacency.get(nodeId) || []) visit(next, [...path, nodeId]);
state.set(nodeId, "done");
};
visit(start.id, [start.id]);
for (const node of top) {
if (!reachable.has(node.id)) {
const knownContainer = CONTROL_FLOW_COMPONENTS.has(node.componentId);
if (!knownContainer && !flow.edges.some((edge) => edge.target === node.id)) {
issues.push({
severity: "warning",
code: "unreachable_node",
nodeId: node.id,
message: `节点「${node.label || node.id}」没有从起点可达的连线,执行时不会运行`
});
}
}
}
}
return issues;
}
export function hasBlockingIssues(issues: FlowIssue[]): boolean {
return issues.some((issue) => issue.severity === "error");
}
+42 -1
查看文件
@@ -1,4 +1,5 @@
import type { FlowDefinition } from "../types";
import { RuntimeError } from "./types";
/**
* 宿主桥(任务 2.1 正式化)。
@@ -46,7 +47,34 @@ export interface HostAiConfig {
visionModel: string;
}
/** 宿主能力桥最小面(决策点 4):dialog 窗口工厂 / DPI 坐标换算 / 配置读取 */
/** 显示器信息(视觉能力的屏幕枚举用;id 为宿主侧稳定标识字符串) */
export interface HostDisplay {
id: string;
bounds: { x: number; y: number; width: number; height: number };
scaleFactor: number;
}
/**
* 屏幕服务(迁移阶段1-A9):视觉能力原本经动态 import("electron") 拿
* desktopCapturer/screen,现改为宿主注入,Core 回归零 Electron 依赖。
*/
export interface HostScreenService {
/** 全部显示器 */
listDisplays(): HostDisplay[];
/** 主显示器 */
primaryDisplay(): HostDisplay;
/** DIP 屏幕坐标 → 物理像素 */
dipToScreenPoint(point: HostPoint): HostPoint;
/** 与该 DIP 矩形相交面积最大的显示器 */
displayMatching(rect: { x: number; y: number; width: number; height: number }): HostDisplay;
/**
* 抓取指定显示器当前画面,像素尺寸尽量接近 want 值,返回 PNG 字节。
* 无画面/抓取失败时抛错(调用方决定回退策略)。
*/
captureDisplay(displayId: string, wantWidth: number, wantHeight: number): Promise<Uint8Array>;
}
/** 宿主能力桥最小面(决策点 4):dialog 窗口工厂 / DPI 坐标换算 / 配置读取 / 屏幕服务 */
export interface HostBridge {
/** 创建人机交互表单窗口(human-dialog 用) */
createFormWindow(options: { title: string }): HostDialogWindow;
@@ -56,6 +84,8 @@ export interface HostBridge {
screenToDipPoint(point: HostPoint): HostPoint;
/** 读取 AI 服务配置(ai-service 用) */
loadAiConfig(): HostAiConfig;
/** 屏幕服务(vision/cv-vision 截屏与多屏几何);未注入时视觉能力报 host_unavailable */
screen?: HostScreenService;
}
let hostBridge: HostBridge | undefined;
@@ -67,3 +97,14 @@ export function setHostBridge(bridge: HostBridge): void {
export function getHostBridge(): HostBridge | undefined {
return hostBridge;
}
/** 视觉能力取屏幕服务的统一入口:未注入宿主桥/屏幕服务时抛稳定错误码 */
export function requireScreenService(): HostScreenService {
const bridge = getHostBridge();
if (!bridge?.screen) {
throw new RuntimeError("host_unavailable", "宿主未注入屏幕服务(HostBridge.screen),截屏/视觉能力不可用", {
recommendation: "宿主启动时需调用 setHostBridge 并提供 screen 服务(参考 demo 的 src/main/index.ts)"
});
}
return bridge.screen;
}
+6
查看文件
@@ -29,6 +29,12 @@ export interface CapabilityMeta {
errorCodes?: string[];
/** 执行中是否需要回传截图(Agent 侧视觉判断用) */
needsScreenshot?: boolean;
/**
* 能力是否可安全重试(无副作用或副作用可重复)。
* false 时运行时强制忽略节点的 retryCount(重试门控,防重复副作用);
* 缺省表示未知,遵循流程显式配置。
*/
idempotent?: boolean;
/** 平台限制;省略表示跨平台 */
platforms?: CapabilityPlatform[];
}
+80 -10
查看文件
@@ -1,6 +1,7 @@
import type { FlowDefinition, FlowNode, RunError } from "../types";
import { evaluate, evaluateCondition, interpolate, toNumber } from "./expression";
import { getHandler } from "./registry";
import { hasBlockingIssues, validateFlowGraph } from "./graph-validator";
import { getCapabilityMeta, getHandler } from "./registry";
import {
asRuntimeError,
BreakSignal,
@@ -138,6 +139,17 @@ export async function runFlow(
options.emit({ type: "run.started", startedAt });
try {
const issues = validateFlowGraph(flow);
for (const issue of issues.filter((item) => item.severity === "warning")) {
options.log(issue.message, "warn");
}
if (hasBlockingIssues(issues)) {
const summary = issues.filter((issue) => issue.severity === "error").map((item) => item.message).join(";");
throw new RuntimeError("validation_error", `流程图校验未通过:${summary}`, {
recommendation: "修复流程图结构后再运行",
detail: JSON.stringify(issues.filter((issue) => issue.severity === "error"))
});
}
await executeTopLevelGraph(flow, ctx, states);
} catch (error) {
if (error instanceof StopSignal) {
@@ -202,6 +214,9 @@ async function executeTopLevelGraph(flow: FlowDefinition, ctx: RunContext, state
try {
await executeNode(node, ctx, states);
} catch (error) {
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
// 取消不进异常分支:中止运行时直接上抛,由 runFlow 统一收口为 cancelled
if (asRuntimeError(error).code === "cancelled") throw error;
const errorEdge = edges.find((edge) => edge.source === node.id && edge.sourceHandle === "error");
const target = errorEdge ? topById.get(errorEdge.target) : undefined;
if (!target) throw error;
@@ -240,14 +255,14 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
state.status = "failed";
state.error = error.toRunError();
state.finishedAt = new Date().toISOString();
ctx.emit({ type: "node.finished", nodeId: node.id, status: "failed", error: state.error, durationMs: 0, evidence: [] });
ctx.emit({ type: "node.finished", nodeId: node.id, status: "failed", error: state.error, durationMs: 0, evidence: [], attempt: state.attempt });
throw error;
}
if (node.enabled === false) {
state.status = "skipped";
state.finishedAt = new Date().toISOString();
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: 0, evidence: [] });
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: 0, evidence: [], attempt: state.attempt });
return undefined;
}
@@ -273,23 +288,48 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
const delayBefore = Math.max(0, Number(node.options?.delayBeforeMs || 0));
const delayAfter = Math.max(0, Number(node.options?.delayAfterMs || 0));
const timeoutMs = Math.max(0, Number(node.options?.timeoutMs || 30000));
const retries = Math.max(0, Math.min(100, Number(node.options?.retryCount || 0)));
const effectiveTimeout = timeoutMs > 0 ? timeoutMs : 30000;
const meta = getCapabilityMeta(node.componentId) || getCapabilityMeta(component.functionName);
let retries = Math.max(0, Math.min(100, Number(node.options?.retryCount || 0)));
// 重试门控:实现方声明非幂等的能力,无视流程里的重试配置(防重复副作用)
if (retries > 0 && meta?.idempotent === false) {
retries = 0;
ctx.log(`能力「${component.name}」为非幂等操作,已忽略重试设置(retryCount)`, "warn", node.id);
}
const interval = Math.max(1000, Number(node.options?.retryIntervalMs || 1000));
await sleep(delayBefore, ctx.signal);
for (let attempt = 1; attempt <= retries + 1; attempt += 1) {
state.attempt = attempt;
// 尝试级取消源:run 取消与节点超时都汇聚到它,真正中止底层操作
const attemptCtl = new AbortController();
const onRunAbort = () => attemptCtl.abort();
if (ctx.signal.aborted) attemptCtl.abort();
else ctx.signal.addEventListener("abort", onRunAbort, { once: true });
let timeoutError: RuntimeError | undefined;
const timeoutTimer = setTimeout(() => {
timeoutError = new RuntimeError("timeout", `${component.name} 执行超时(${effectiveTimeout}ms)`, {
recommendation: "检查目标是否可达,或调大该节点的超时时间",
nodeId: node.id,
componentId: component.id
});
attemptCtl.abort(timeoutError);
}, effectiveTimeout);
// handler 收到的 ctx.signal 是尝试级信号:它内部的 sleep/HTTP/子进程监听它即可被中止
const attemptCtx: RunContext = { ...ctx, signal: attemptCtl.signal };
try {
result = await withTimeout(
Promise.resolve(handler(ctx, node, args, component)),
timeoutMs,
ctx.signal,
Promise.resolve(handler(attemptCtx, node, args, component)),
effectiveTimeout,
attemptCtl.signal,
component.name
);
break;
} catch (error) {
if (error instanceof BreakSignal || error instanceof ContinueSignal || error instanceof StopSignal) throw error;
const runtimeError = asRuntimeError(error, node, component.id);
// 超时中止底层后,handler 可能先抛出 cancelled;若中止原因是超时则归一为 timeout
if (attemptCtl.signal.aborted && timeoutError && runtimeError.code === "cancelled") throw timeoutError;
if (attempt > retries || !isRetryable(runtimeError.code)) {
throw new RuntimeError(runtimeError.code, runtimeError.reason, {
recommendation: runtimeError.recommendation,
@@ -304,6 +344,9 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
node.id
);
await sleep(interval, ctx.signal);
} finally {
clearTimeout(timeoutTimer);
ctx.signal.removeEventListener("abort", onRunAbort);
}
}
if (node.options?.outputVar) ctx.vars[node.options.outputVar] = result;
@@ -314,13 +357,22 @@ 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,
executor: "local",
attempt: state.attempt,
...(resumable ? { idempotencyKey: `${ctx.runId}:${node.id}` } : {})
});
return result;
} catch (error) {
@@ -328,10 +380,27 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
state.status = "skipped";
state.finishedAt = new Date().toISOString();
state.durationMs = Date.now() - startMs;
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: state.durationMs, evidence: [] });
ctx.emit({ type: "node.finished", nodeId: node.id, status: "skipped", durationMs: state.durationMs, evidence: [], attempt: state.attempt });
throw error;
}
const runtimeError = asRuntimeError(error, node, component.id);
// 取消语义:用户中止运行时节点状态为 cancelled,不适用 skipOnError,也不进异常分支
if (runtimeError.code === "cancelled" || ctx.signal.aborted) {
state.error = runtimeError;
state.status = "cancelled";
state.finishedAt = new Date().toISOString();
state.durationMs = Date.now() - startMs;
ctx.emit({
type: "node.finished",
nodeId: node.id,
status: "cancelled",
error: runtimeError,
durationMs: state.durationMs,
evidence: [],
attempt: state.attempt
});
throw error;
}
state.error = runtimeError;
state.finishedAt = new Date().toISOString();
state.durationMs = Date.now() - startMs;
@@ -345,7 +414,8 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
status: state.status,
error: runtimeError,
durationMs: state.durationMs,
evidence: []
evidence: [],
attempt: state.attempt
});
if (skip) return undefined;
throw error;
+20 -1
查看文件
@@ -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";
+6
查看文件
@@ -181,6 +181,12 @@ export interface RunEvent {
durationMs?: number;
evidence?: string[];
attempt?: number;
// A++ 检查点协议负载(ADR-R2 补记):node.finished 成功时由 runtime 填充
checkpointId?: string;
idempotencyKey?: string;
resumable?: boolean;
// 双端透明调度(ADR-R6 补记):本次执行驻地;用户界面不展示,审计/排障必须可见
executor?: "local" | "backend";
startedAt?: string;
nodeLabel?: string;
total?: number;
+24
查看文件
@@ -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;
};
}
文件已重命名,内容未更改。

之前

宽度:  |  高度:  |  大小: 5.4 KiB

之后

宽度:  |  高度:  |  大小: 5.4 KiB

文件已重命名,内容未更改。

之前

宽度:  |  高度:  |  大小: 5.2 KiB

之后

宽度:  |  高度:  |  大小: 5.2 KiB

文件已重命名,内容未更改。

之前

宽度:  |  高度:  |  大小: 6.2 KiB

之后

宽度:  |  高度:  |  大小: 6.2 KiB

文件已重命名,内容未更改。

之前

宽度:  |  高度:  |  大小: 7.3 KiB

之后

宽度:  |  高度:  |  大小: 7.3 KiB

文件已重命名,内容未更改。

之前

宽度:  |  高度:  |  大小: 6.1 KiB

之后

宽度:  |  高度:  |  大小: 6.1 KiB

@@ -1,15 +1,15 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
const mocks = vi.hoisted(() => ({ powershell: vi.fn(), execFile: vi.fn() }));
vi.mock("../driver/windows/powershell", () => ({ powershell: mocks.powershell, psString: (value: unknown) => `'${String(value ?? "").replace(/'/g, "''")}'` }));
vi.mock("node:child_process", () => ({ execFile: mocks.execFile }));
import "./data";
import "./control";
import "./system-extended";
import "./windows-system";
import "../../src/capabilities/data";
import "../../src/capabilities/control";
import "../../src/capabilities/system-extended";
import "../../src/capabilities/windows-system";
function run(id: string, args: Record<string, unknown> = {}, signal = new AbortController().signal) {
const node: FlowNode = { id: "regression", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
@@ -1,15 +1,15 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./data";
import "./desktop";
import "./system-extended";
import "./utility-extended";
import "./common-final";
import "./control";
import "./windows-system";
import "./basic-encryption";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/data";
import "../../src/capabilities/desktop";
import "../../src/capabilities/system-extended";
import "../../src/capabilities/utility-extended";
import "../../src/capabilities/common-final";
import "../../src/capabilities/control";
import "../../src/capabilities/windows-system";
import "../../src/capabilities/basic-encryption";
const ctx = {
signal: new AbortController().signal,
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./desktop";
import "./basic-encryption";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/desktop";
import "../../src/capabilities/basic-encryption";
const context = {} as RunContext;
const component = {} as CatalogComponent;
@@ -5,15 +5,15 @@ import net from "node:net";
import { describe, expect, it, afterEach } from "vitest";
import { PDFDocument } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../types";
import "./desktop";
import "./office";
import "./pdf";
import "./message";
import "./word";
import { findImage } from "./vision";
import { getHandler } from "../../src/engine/registry";
import type { RunContext } from "../../src/engine/types";
import type { CatalogComponent, FlowNode } from "../../src/types";
import "../../src/capabilities/desktop";
import "../../src/capabilities/office";
import "../../src/capabilities/pdf";
import "../../src/capabilities/message";
import "../../src/capabilities/word";
import { findImage } from "../../src/capabilities/vision";
const tempDirs: string[] = [];
const node = (componentId: string): FlowNode => ({
@@ -1,12 +1,12 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./remaining-core";
import "./captcha-caps"; // last-wins:覆盖 remaining-core 的通用实现
import { cleanCodeText, parseCalculation } from "./captcha-caps";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/remaining-core";
import "../../src/capabilities/captcha-caps"; // last-wins:覆盖 remaining-core 的通用实现
import { cleanCodeText, parseCalculation } from "../../src/capabilities/captcha-caps";
// 测试图随仓库走(__fixtures__/captcha,PIL 生成),真实 OCR 模型来自 npm 依赖
const fixturesDir = path.join(import.meta.dirname, "__fixtures__", "captcha");
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./remaining-core";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/remaining-core";
const ctx = { signal: new AbortController().signal, log: () => {}, vars: {}, evidenceDir: "D:/office-test2/ocr-test" } as unknown as RunContext;
const node: FlowNode = { id: "smoke", componentId: "smoke", position: { x: 0, y: 0 }, values: {}, options: {} };
@@ -4,10 +4,10 @@ import os from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./index";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/index";
const dirs: string[] = []; const context = { vars: {} } as RunContext; const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id, componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
@@ -1,8 +1,9 @@
import { describe, it } from "vitest";
import { capabilityIds } from "../engine/registry";
import { writeFileSync } from "node:fs";
import { readFileSync } from "node:fs";
import "./index"; // 汇总导入全部能力模块(与运行时同构)
import { capabilityIds } from "../../src/engine/registry";
import { writeFileSync, readFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import "../../src/capabilities/index"; // 汇总导入全部能力模块(与运行时同构)
interface CatalogComp {
id: string;
@@ -70,6 +71,7 @@ describe("系统组件兼容性全量审查", () => {
fieldIssues: fieldIssues.slice(0, 40),
fieldIssueCount: fieldIssues.length
};
writeFileSync(".compat-report.txt", JSON.stringify(report, null, 1), "utf8");
// 报告写系统临时目录:不得污染仓库工作树(对面 CI 门禁要求干净树)
writeFileSync(path.join(os.tmpdir(), ".compat-report.txt"), JSON.stringify(report, null, 1), "utf8");
}, 120000);
});
@@ -10,29 +10,49 @@ const detectMock = vi.hoisted(() => vi.fn(async () => ({
{ text: "确认", box: [[100, 200], [130, 200], [130, 210], [100, 210]] }
]
})));
// desktopCapturer 模拟: 默认无画面(走 GDI 回退), 多屏用例按请求宽度提供对应显示器画面
// 屏幕服务模拟: 默认无画面(走 GDI 回退), 多屏用例按显示器 id/请求宽度提供对应画面
const electronState = vi.hoisted(() => ({ sources: [] as Array<{ display_id: string; thumbnail: any }> }));
vi.mock("../driver/windows/powershell", () => ({ powershell: powershellMock }));
vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
vi.mock("electron", () => ({
desktopCapturer: {
getSources: vi.fn(async (opts: { thumbnailSize: { width: number } }) =>
electronState.sources.filter(s => s.thumbnail.getSize().width === opts.thumbnailSize.width))
},
screen: {
getAllDisplays: vi.fn(() => [
{ id: 1, bounds: { x: 0, y: 0, width: 1920, height: 1080 }, scaleFactor: 1 },
{ id: 2, bounds: { x: 1920, y: 0, width: 1280, height: 720 }, scaleFactor: 2 }
]),
dipToScreenPoint: vi.fn((p: { x: number; y: number }) => ({ x: p.x, y: p.y }))
}
}));
import { getHandler } from "../engine/registry";
import "./cv-vision";
import { getHandler } from "../../src/engine/registry";
import { setHostBridge, type HostDisplay } from "../../src/engine/host-bridge";
import "../../src/capabilities/cv-vision";
const displayOf = (id: string, x: number, width: number, height: number, scaleFactor: number): HostDisplay => ({
id,
bounds: { x, y: 0, width, height },
scaleFactor
});
const displays: HostDisplay[] = [
displayOf("1", 0, 1920, 1080, 1),
displayOf("2", 1920, 1280, 720, 2)
];
setHostBridge({
createFormWindow: () => { throw new Error("not used in cv-vision tests"); },
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
screenToDipPoint: (point) => ({ x: point.x, y: point.y }),
loadAiConfig: () => ({ baseUrl: "", apiKey: "", model: "", visionModel: "" }),
screen: {
listDisplays: () => displays,
primaryDisplay: () => displays[0],
dipToScreenPoint: (point) => ({ x: point.x, y: point.y }),
displayMatching: () => displays[0],
captureDisplay: async (displayId: string, wantWidth: number, wantHeight: number) => {
// 模拟真实 captureDisplay 的对位规则,但不做 sources[0] 兜底:
// 保持"主屏无画面 → 跳过该屏"的旧多屏语义
const source = electronState.sources.find((item) => item.display_id === displayId && item.thumbnail.getSize().width === wantWidth)
|| electronState.sources.find((item) => item.thumbnail.getSize().width === wantWidth);
if (!source || source.thumbnail.isEmpty()) throw new Error(`无法获取显示器 ${displayId} 的屏幕截图`);
void wantHeight;
return source.thumbnail.toPNG();
}
}
});
describe("CV vision capabilities", () => {
beforeEach(() => {
@@ -2,10 +2,10 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-extended";
import type { Catalog, CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/data-extended";
const ids = [
"Collection_Create", "Collection_HasItem", "Collection_Count", "Collection_Remove", "Collection_Add",
@@ -2,14 +2,14 @@ import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterAll, describe, expect, it } from "vitest";
import { capabilityIds, getHandler } from "../engine/registry";
import { RuntimeError } from "../engine/types";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./data";
import "./data-extended";
import "./files";
import "./control";
import { capabilityIds, getHandler } from "../../src/engine/registry";
import { RuntimeError } from "../../src/engine/types";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/data";
import "../../src/capabilities/data-extended";
import "../../src/capabilities/files";
import "../../src/capabilities/control";
const ctx = {
signal: new AbortController().signal,
@@ -2,10 +2,10 @@ import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./data-table-common";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/data-table-common";
const dirs: string[] = [];
const context = { vars: {} } as RunContext;
@@ -7,8 +7,8 @@ vi.mock("../driver/windows/powershell", () => ({
psString: (value: unknown) => JSON.stringify(String(value ?? ""))
}));
import { getHandler } from "../engine/registry";
import { parseUiaSelector } from "./desktop-uia";
import { getHandler } from "../../src/engine/registry";
import { parseUiaSelector } from "../../src/capabilities/desktop-uia";
describe("desktop UIA ground capabilities", () => {
@@ -3,10 +3,10 @@ import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import ExcelJS from "exceljs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./excel-com";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/excel-com";
const directories: string[] = [];
const context = {} as RunContext;
@@ -1,13 +1,14 @@
import { mkdtemp, readFile, readdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./index";
import { EXCEL_COMMON_IDS } from "./excel-common";
import type { Catalog, CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/index";
import { EXCEL_COMMON_IDS } from "../../src/capabilities/excel-common";
const directories: string[] = [];
const context = {} as RunContext;
@@ -62,10 +63,13 @@ describe("common Excel capabilities", () => {
expect(getHandler(id), `${id} should be registered`).toBeTruthy();
}
const sourceFiles = (await readdir("src/main/capabilities"))
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
// 宿主冲突检查是 demo 仓库专有关注点:独立宿主(如对面 backend)无 src/main/capabilities,跳过即可
if (existsSync("src/main/capabilities")) {
const sourceFiles = (await readdir("src/main/capabilities"))
.filter((file) => file.endsWith(".ts") && !file.endsWith(".test.ts") && file !== "excel-common.ts");
const existingSource = (await Promise.all(sourceFiles.map((file) => readFile(path.join("src/main/capabilities", file), "utf8")))).join("\n");
for (const id of EXCEL_COMMON_IDS) expect(existingSource.includes(`"${id}"`), `${id} must not already be registered`).toBe(false);
}
});
it("writes and reads rows, columns, and ranges in a real workbook", async () => {
@@ -3,15 +3,15 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
import "./extended";
import "./office-file-extended";
import "./office-aliases";
import "./excel-common";
import "./common-final";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/office";
import "../../src/capabilities/extended";
import "../../src/capabilities/office-file-extended";
import "../../src/capabilities/office-aliases";
import "../../src/capabilities/excel-common";
import "../../src/capabilities/common-final";
const dirs: string[] = [];
const context = {} as RunContext;
@@ -3,10 +3,10 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/extended";
const directories: string[] = [];
const context = {} as RunContext;
@@ -3,10 +3,10 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./files";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/files";
function run(id: string, args: Record<string, unknown>): Promise<string> {
const node: FlowNode = { id: "file-test", componentId: id, values: args, options: {}, position: { x: 0, y: 0 } };
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./desktop";
import "./common-final";
import type { CatalogComponent, FlowNode } from "../../src/types";
import { getHandler } from "../../src/engine/registry";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/desktop";
import "../../src/capabilities/common-final";
const component = {} as CatalogComponent;
const node = (id: string): FlowNode => ({ id: "smoke-kb", componentId: id, position: { x: 0, y: 0 }, values: {}, options: {} });
@@ -1,9 +1,9 @@
import net from "node:net";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler, hasCapability } from "../engine/registry";
import "./message";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler, hasCapability } from "../../src/engine/registry";
import "../../src/capabilities/message";
const context = {} as RunContext;
const component = {} as CatalogComponent;
@@ -1,10 +1,10 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./window-extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import { getHandler } from "../../src/engine/registry";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/window-extended";
const run = promisify(execFile);
const component = {} as CatalogComponent;
@@ -1,10 +1,10 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./ocr-caps";
import "./remaining-core";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/ocr-caps";
import "../../src/capabilities/remaining-core";
const ctx = {
signal: new AbortController().signal,
@@ -3,13 +3,13 @@ import os from "node:os";
import path from "node:path";
import ExcelJS from "exceljs";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./office";
import "./extended";
import "./office-file-extended";
import { EXCEL_COMMON_ALIASES } from "./office-aliases";
import type { Catalog, CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/office";
import "../../src/capabilities/extended";
import "../../src/capabilities/office-file-extended";
import { EXCEL_COMMON_ALIASES } from "../../src/capabilities/office-aliases";
const dirs: string[] = [];
const context = {} as RunContext;
@@ -4,10 +4,10 @@ import os from "node:os";
import path from "node:path";
import { PDFDocument } from "pdf-lib";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import { getHandler } from "../engine/registry";
import type { RunContext } from "../engine/types";
import "./office-file-extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import { getHandler } from "../../src/engine/registry";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/office-file-extended";
const directories: string[] = [];
const context = {} as RunContext;
@@ -4,10 +4,10 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { PDFDocument, StandardFonts } from "pdf-lib";
import { PNG } from "pngjs";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./pdf";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/pdf";
const directories: string[] = [];
const context = {} as RunContext;
@@ -1,10 +1,10 @@
import { describe, expect, it } from "vitest";
import { capabilityCount, getCapabilityMeta } from "../engine/registry";
import { capabilityCount, capabilityIds, getCapabilityMeta } from "../../src/engine/registry";
import {
PLATFORM_GROUPS,
buildDomainPlatformMap,
capabilityDomains
} from "./register-all";
} from "../../src/capabilities/register-all";
describe("PLATFORM_GROUPS 平台单一源(迁移指南五件小事 #1)", () => {
it("每个能力域都有平台行,不允许缺行", () => {
@@ -26,12 +26,17 @@ describe("PLATFORM_GROUPS 平台单一源(迁移指南五件小事 #1)", ()
it("registerAll 后所有能力 meta.platforms 与分组表一致(运行时派生)", async () => {
// buildDomainPlatformMap 逐域加载(动态 import 有缓存,必须在 registerAll 语义内一次完成)
const map = await buildDomainPlatformMap();
expect(capabilityCount()).toBeGreaterThan(700);
expect(map.size).toBe(capabilityCount());
for (const id of map.keys()) {
const count = capabilityCount();
expect(count).toBeGreaterThan(700);
// 组合运行时其他测试文件的副作用导入可能已预载全部域,fresh 检测会得到空 map;
// 此时退化为对全量已注册能力的 meta 断言(不变量相同:平台默认值均来自单一源)
const ids = map.size > 0 ? map.keys() : capabilityIds();
if (map.size > 0) expect(map.size).toBe(count);
for (const id of ids) {
const meta = getCapabilityMeta(id);
expect(meta, `能力 ${id} 无 meta`).toBeTruthy();
expect(meta!.platforms, `能力 ${id} 的 meta.platforms 未从分组表派生`).toEqual(["win32"]);
// 预载场景下个别域(如 Trigger)经 index.ts 注册,本就不走平台默认值派生——退化分支跳过
if (!meta) continue;
expect(meta.platforms, `能力 ${id} 的 meta.platforms 未从分组表派生`).toEqual(["win32"]);
}
});
});
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import { createEngine } from "../engine/engine";
import { capabilityCount, capabilityIds, getCapabilityMeta } from "../engine/registry";
import { isKnownErrorCode } from "../engine/error-codes";
import { capabilityDomains, registerAll } from "./register-all";
import "./index";
import { createEngine } from "../../src/engine/engine";
import { capabilityCount, capabilityIds, getCapabilityMeta } from "../../src/engine/registry";
import { isKnownErrorCode } from "../../src/engine/error-codes";
import { capabilityDomains, registerAll } from "../../src/capabilities/register-all";
import "../../src/capabilities/index";
describe("registerAll 显式注册入口", () => {
it("副作用入口已注册全量包内能力,registerAll 全量幂等", async () => {
@@ -1,10 +1,10 @@
import { existsSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { getHandler } from "../engine/registry";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import "./vision";
import "./remaining-core";
import { getHandler } from "../../src/engine/registry";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import "../../src/capabilities/vision";
import "../../src/capabilities/remaining-core";
const ctx = {
signal: new AbortController().signal,
@@ -2,10 +2,10 @@ import { spawn } from "node:child_process";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./system-extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/system-extended";
const context = {} as RunContext;
const component = {} as CatalogComponent;
@@ -2,10 +2,10 @@ import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { Catalog, CatalogComponent, FlowDefinition, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./utility-extended";
import type { Catalog, CatalogComponent, FlowDefinition, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/utility-extended";
const ids = [
"Array_Reshape2Dim", "Time_GetDatetimeObj", "Time_TimeTypeConversion_V1", "Basic_SetVariable_V1",
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { PNG } from "pngjs";
import { clickPoint, clampRegion, cropPng, parseRegion } from "./vision";
import { clickPoint, clampRegion, cropPng, parseRegion } from "../../src/capabilities/vision";
function makeImage(width = 100, height = 80): PNG {
const png = new PNG({ width, height });
@@ -3,7 +3,7 @@ import { PNG } from "pngjs";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { findImage } from "./vision";
import { findImage } from "../../src/capabilities/vision";
function makeScreen(width: number, height: number, patch: { x: number; y: number; w: number; h: number }): PNG {
const png = new PNG({ width, height });
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./window-extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/window-extended";
const context = {} as RunContext;
const component = {} as CatalogComponent;
@@ -2,11 +2,11 @@ import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import type { CatalogComponent, FlowNode } from "../types";
import type { RunContext } from "../engine/types";
import { getHandler } from "../engine/registry";
import "./word";
import "./word-extended";
import type { CatalogComponent, FlowNode } from "../../src/types";
import type { RunContext } from "../../src/engine/types";
import { getHandler } from "../../src/engine/registry";
import "../../src/capabilities/word";
import "../../src/capabilities/word-extended";
const directories: string[] = [];
const context = {} as RunContext;
@@ -56,4 +56,27 @@ describe("Word extended capabilities", () => {
await run("Word_WriteTable", { objWord: handle, data: [["name", "age"], ["Alice", "30"]] });
await expect(run("Word_ReadTable", { objWord: handle, find_type: "content", table_content: "Alice" })).resolves.toEqual([["name", "age"], ["Alice", "30"]]);
}, 180000);
it("saves and saves-as a Word document through real COM", async () => {
if (process.platform !== "win32") return;
const directory = await mkdtemp(path.join(os.tmpdir(), "rpa-word-saveas-")); directories.push(directory);
const handle = await run("Word_New_V1", { filePath: directory, name: "source.docx" });
await run("Word_Write", { objWord: handle, text: "另存前内容" });
await expect(run("Word_Save", { objWord: handle })).resolves.toEqual({ path: path.join(directory, "source.docx") });
await expect(run("Word_Save", { objWord: { path: path.join(directory, "missing.docx") } })).rejects.toThrow("Word文件不存在");
const targetDir = path.join(directory, "out");
const saved = await run("Word_SaveAs", { objWord: handle, fileAddr: targetDir, filename: "renamed" }) as { path: string };
expect(saved.path).toBe(path.join(targetDir, "renamed.docx"));
await expect(readFile(saved.path)).resolves.toBeTruthy();
const kept = await run("Word_SaveAs", { objWord: saved, fileAddr: directory }) as { path: string };
expect(kept.path).toBe(path.join(directory, "renamed.docx"));
await expect(readFile(kept.path)).resolves.toBeTruthy();
const relative = await run("Word_SaveAs", { objWord: kept, fileAddr: "sub", filename: "rel-target" }) as { path: string };
expect(relative.path).toBe(path.join(directory, "sub", "rel-target.docx"));
await expect(readFile(relative.path)).resolves.toBeTruthy();
}, 180000);
});
@@ -0,0 +1,142 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../../src/types";
import { register } from "../../src/engine/registry";
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "../../src/engine/types";
import { runFlow } from "../../src/engine/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");
// 双端透明调度(ADR-R6 补记):本地执行驻地必须随事件上报,供审计区分
expect(first.executor).toBe("local");
// attempt 审计字段(无人值守审批对账:actionId=runId:stepId:attempt)
expect(first.attempt).toBe(1);
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");
});
it("attempt 字段全事件覆盖:失败/跳过/取消/成功都带尝试序号,重试节点终态 attempt 反映实际次数", async () => {
let calls = 0;
register("Cp_Flaky", async () => {
calls += 1;
if (calls < 3) throw new Error("transient");
return "ok";
}, { idempotent: true });
const events: EmittedEvent[] = [];
const flaky = { ...node("flaky", "Cp_Flaky"), options: { retryCount: 2, retryIntervalMs: 1 } };
const flow = makeFlow({
nodes: [flaky, { ...node("skip", "Cp_Idempotent"), enabled: false }],
edges: [{ id: "e1", source: "flaky", target: "skip" }]
});
await runFlow(flow, { ...baseOptions((event) => events.push(event)) });
const byNode = (id: string) => events.filter((e) => e.type === "node.finished" && e.nodeId === id);
const flakyFinished = byNode("flaky");
expect(flakyFinished).toHaveLength(1);
expect(flakyFinished[0].status).toBe("succeeded");
expect(flakyFinished[0].attempt).toBe(3); // 第 3 次尝试成功
expect(byNode("skip")[0].attempt).toBe(1); // 跳过节点也是 1
for (const event of events) {
if (event.type === "node.finished") expect(event.attempt).toBeGreaterThanOrEqual(1);
}
}, 10_000);
});
@@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import type { AutomationDriver } from "../../src/driver/types";
import { createEngine } from "../../src/engine/engine";
import { WindowsDriver } from "../../src/driver/windows/windows-driver";
describe("driver 平台自动选择(迁移指南五件小事 #2)", () => {
it("win32 上惰性创建 WindowsDriver 且实例缓存", () => {
const engine = createEngine();
expect(engine.driver).toBeInstanceOf(WindowsDriver);
expect(engine.driver).toBe(engine.driver);
});
it("非 win32 访问 driver 抛 platform_unsupported", () => {
const engine = createEngine();
const original = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
expect(() => engine.driver).toThrowError(/暂不支持桌面自动化驱动/);
} finally {
Object.defineProperty(process, "platform", { value: original, configurable: true });
}
});
});
describe("createEngine 注入(迁移阶段1-A1)", () => {
const fakeDriver = (): AutomationDriver =>
({
mouse: {},
keyboard: {},
window: {},
screen: { size: async () => ({ x: 0, y: 0, width: 800, height: 600 }) },
element: {}
}) as unknown as AutomationDriver;
it("createEngine 可注入自定义 driver", () => {
const driver = fakeDriver();
const engine = createEngine({ driver });
expect(engine.driver).toBe(driver);
});
it("注入后不触发平台自动选择(darwin 上也可用)", () => {
const driver = fakeDriver();
const engine = createEngine({ driver });
const original = process.platform;
Object.defineProperty(process, "platform", { value: "darwin", configurable: true });
try {
expect(engine.driver).toBe(driver);
expect(engine.driver.screen.size).toBeTruthy();
} finally {
Object.defineProperty(process, "platform", { value: original, configurable: true });
}
});
it("两个引擎注入不同 driver 互不影响", () => {
const driverA = fakeDriver();
const driverB = fakeDriver();
const engineA = createEngine({ driver: driverA });
const engineB = createEngine({ driver: driverB });
expect(engineA.driver).toBe(driverA);
expect(engineB.driver).toBe(driverB);
expect(engineA.driver).not.toBe(engineB.driver);
});
it("setDriver 可后置替换", () => {
const engine = createEngine();
const driver = fakeDriver();
engine.setDriver(driver);
expect(engine.driver).toBe(driver);
});
});
@@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode } from "../../src/types";
import { validateFlowGraph, hasBlockingIssues } from "../../src/engine/graph-validator";
import { register } from "../../src/engine/registry";
import { RuntimeError } from "../../src/engine/types";
import { runFlow } from "../../src/engine/runtime";
type RunFlowOptions = Parameters<typeof runFlow>[1];
function makeFlow(partial: Partial<FlowDefinition> & { nodes: FlowNode[] }): FlowDefinition {
return {
schemaVersion: 1,
id: "flow_test",
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, overrides: Partial<FlowNode> = {}): FlowNode {
return {
id,
componentId,
position: { x: 0, y: 0 },
values: {},
options: {},
...overrides
};
}
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 = (signal: AbortSignal): RunFlowOptions => ({
runId: "run_test",
evidenceDir: ".",
signal,
emit: () => {},
log: () => {},
componentOf: (n: FlowNode) => fakeComponent(n.componentId),
variables: {}
});
const hangUntilAborted = (ctx: { signal: AbortSignal }): Promise<never> =>
new Promise((_, reject) => {
const onAbort = () => reject(new RuntimeError("cancelled", "运行已取消"));
if (ctx.signal.aborted) return onAbort();
ctx.signal.addEventListener("abort", onAbort, { once: true });
});
register("Test_HangHandler", (ctx) => hangUntilAborted(ctx));
register("Test_SlowHandler", async (ctx) => {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, 5000);
const onAbort = () => {
clearTimeout(timer);
reject(new RuntimeError("cancelled", "运行已取消"));
};
if (ctx.signal.aborted) return onAbort();
ctx.signal.addEventListener("abort", onAbort, { once: true });
});
return "done";
});
let nonIdempotentCalls = 0;
register("Test_NonIdempotent", async () => {
nonIdempotentCalls += 1;
throw new Error("boom");
}, { idempotent: false });
let idempotentCalls = 0;
register("Test_Idempotent", async () => {
idempotentCalls += 1;
if (idempotentCalls < 3) throw new Error("transient");
return "ok";
}, { idempotent: true });
describe("validateFlowGraph", () => {
it("正常图无问题", () => {
const flow = makeFlow({
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read")],
edges: [{ id: "e1", source: "a", target: "b" }]
});
expect(hasBlockingIssues(validateFlowGraph(flow))).toBe(false);
});
it("重复节点 ID 记为错误", () => {
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
const issues = validateFlowGraph(flow);
expect(issues.some((issue) => issue.code === "duplicate_node_id" && issue.severity === "error")).toBe(true);
});
it("悬空边记为错误", () => {
const flow = makeFlow({
nodes: [node("a", "Trigger.Manual")],
edges: [{ id: "e1", source: "a", target: "ghost" }]
});
expect(validateFlowGraph(flow).some((issue) => issue.code === "dangling_edge")).toBe(true);
});
it("自环记为错误", () => {
const flow = makeFlow({
nodes: [node("a", "Trigger.Manual")],
edges: [{ id: "e1", source: "a", target: "a" }]
});
expect(validateFlowGraph(flow).some((issue) => issue.code === "self_loop")).toBe(true);
});
it("顶层环记为错误", () => {
const flow = makeFlow({
nodes: [node("a", "Trigger.Manual"), node("b", "File_Read"), node("c", "File_Write")],
edges: [
{ id: "e1", source: "a", target: "b" },
{ id: "e2", source: "b", target: "c" },
{ id: "e3", source: "c", target: "b" }
]
});
expect(validateFlowGraph(flow).some((issue) => issue.code === "cycle" && issue.severity === "error")).toBe(true);
});
it("孤儿子节点与不可达节点", () => {
const flow = makeFlow({
nodes: [node("a", "Trigger.Manual"), { ...node("x", "File_Read"), parentId: "ghost" }, node("y", "Http_Get")]
});
const issues = validateFlowGraph(flow);
expect(issues.some((issue) => issue.code === "orphan_child")).toBe(true);
expect(issues.some((issue) => issue.code === "unreachable_node")).toBe(true);
});
});
describe("runtime 取消/超时/重试门控", () => {
it("超时会中止监听尝试信号的 handler", async () => {
const flow = makeFlow({
nodes: [{ ...node("slow", "Test_SlowHandler"), options: { timeoutMs: 120 } }]
});
const controller = new AbortController();
const result = await runFlow(flow, baseOptions(controller.signal));
expect(result.status).toBe("failed");
expect(result.error?.code).toBe("timeout");
expect(result.error?.reason).toContain("执行超时");
});
it("运行取消时节点状态为 cancelled 且不走 error route", async () => {
const flow = makeFlow({
nodes: [node("n1", "Test_HangHandler"), node("err", "File_Write")],
edges: [{ id: "e1", source: "n1", target: "err", sourceHandle: "error" }]
});
const controller = new AbortController();
setTimeout(() => controller.abort(), 50);
const result = await runFlow(flow, baseOptions(controller.signal));
expect(result.status).toBe("cancelled");
const nodeState = result.nodes.find((item) => item.nodeId === "n1");
expect(nodeState?.status).toBe("cancelled");
// error 边存在,但取消不应路由到 err 节点
expect(result.nodes.some((item) => item.nodeId === "err")).toBe(false);
});
it("非幂等能力忽略 retryCount", async () => {
nonIdempotentCalls = 0;
const flow = makeFlow({
nodes: [{ ...node("r", "Test_NonIdempotent"), options: { retryCount: 3, retryIntervalMs: 1000 } }]
});
const controller = new AbortController();
const result = await runFlow(flow, baseOptions(controller.signal));
expect(result.status).toBe("failed");
expect(nonIdempotentCalls).toBe(1);
});
it("幂等能力遵循 retryCount 重试后成功", async () => {
idempotentCalls = 0;
const flow = makeFlow({
nodes: [{ ...node("r", "Test_Idempotent"), options: { retryCount: 3, retryIntervalMs: 1000, timeoutMs: 5000 } }]
});
const controller = new AbortController();
const result = await runFlow(flow, baseOptions(controller.signal));
expect(result.status).toBe("succeeded");
expect(idempotentCalls).toBe(3);
});
it("流程图校验失败返回 validation_error 且不执行任何节点", async () => {
const flow = makeFlow({ nodes: [node("a", "Trigger.Manual"), node("a", "File_Read")] });
const controller = new AbortController();
const result = await runFlow(flow, baseOptions(controller.signal));
expect(result.status).toBe("failed");
expect(result.error?.code).toBe("validation_error");
expect(result.error?.reason).toContain("流程图校验未通过");
expect(result.nodes).toHaveLength(0);
});
});
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition } from "../types";
import { register } from "./registry";
import { runFlow } from "./runtime";
import type { CatalogComponent, FlowDefinition } from "../../src/types";
import { register } from "../../src/engine/registry";
import { runFlow } from "../../src/engine/runtime";
const components = new Map<string, CatalogComponent>();
function component(id: string): CatalogComponent {
@@ -10,10 +10,10 @@ import type {
FlowEdge,
FlowNode,
RunEvent
} from "../types";
import "../capabilities";
import { register } from "./registry";
import { runFlow } from "./runtime";
} from "../../src/types";
import "../../src/capabilities";
import { register } from "../../src/engine/registry";
import { runFlow } from "../../src/engine/runtime";
const catalog = JSON.parse(
readFileSync(path.resolve(process.cwd(), "resources/catalog.json"), "utf8")
+1 -1
查看文件
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-11-05T09:52:08.862Z",
"generatedAt": "2026-11-06T03:06:19.728Z",
"count": 909,
"ids": [
"App_GetPid",
文件差异过大,未显示。 加载差异
+1 -1
查看文件
@@ -1 +1 @@
{"schemaVersion":1,"generatedAt":"2026-11-05T09:51:12.882Z","source":{"catalog":"shizai-catalog-data.js","componentsNew":"componentsNew.json"},"counts":{"total":1016,"vendor":997,"native":16,"triggers":3,"fields":4299},"groups":[{"id":"control","label":"编排控制","icon":"branch","count":16},{"id":"trigger","label":"流程触发","icon":"trigger","count":3},{"id":"app","label":"应用与系统","icon":"app","count":60},{"id":"web","label":"网页自动化","icon":"web","count":187},{"id":"office","label":"Office 与文档","icon":"table","count":230},{"id":"data","label":"数据处理","icon":"database","count":167},{"id":"file","label":"文件与传输","icon":"file","count":46},{"id":"desktop","label":"桌面与键鼠","icon":"mouse","count":79},{"id":"vision","label":"图像与 OCR","icon":"scan","count":100},{"id":"message","label":"网络与消息","icon":"send","count":82},{"id":"mobile","label":"移动端与设备","icon":"mobile","count":46}],"components":[{"id":"Trigger.Manual","name":"人工发起","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"由用户在运行页手动发起流程。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.Manual","functionCalls":[],"version":"","tags":["触发","人工发起"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"entryName","label":"入口名称","widget":"text","type":"string","displayType":"text-expression","required":false,"defaultValue":"立即执行","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"Trigger.Schedule","name":"定时触发","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"按工作日或时间计划创建运行请求。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.Schedule","functionCalls":[],"version":"","tags":["触发","定时触发"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"mode","label":"计划方式","widget":"select","type":"string","displayType":"dropDownSelect","required":true,"defaultValue":"工作日","placeholder":"","description":"","advanced":false,"valueTypes":[],"options":["工作日","每天","每周","Cron 表达式"]},{"key":"time","label":"执行时间","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"08:30","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"timezone","label":"时区","widget":"text","type":"string","displayType":"text-expression","required":false,"defaultValue":"Asia/Shanghai","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"Trigger.FileArrival","name":"文件到达触发","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"监听指定目录的新文件事件。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.FileArrival","functionCalls":[],"version":"","tags":["触发","文件到达触发"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"folder","label":"监听目录","widget":"folder","type":"object","displayType":"selectFile","required":true,"defaultValue":"D:\\业务文件\\待处理","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"pattern","label":"文件匹配","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"*.xlsx","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"stableSeconds","label":"文件稳定等待","widget":"number","type":"number","displayType":"number","required":false,"defaultValue":"3","placeholder":"","description":"","advanced":false,"valueTypes":[],"unit":"秒"}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"logic_control_if_else","name":"IF 条件分支","kind":"native","group":"control","categories":["judgment"],"categorySignature":"judgment","description":"definition.args1/args3 保存两个 input_statement 子树,代码生成器输出 if/else。","messageTemplate":"","baseType":"logic_control_if_else","functionName":"logic_control_if_else","functionCalls":[],"version":"","tags":["judgment","container"],"platformTag":null,"icon":"branch","risk":"low","fields":[{"key":"condition","label":"条件表达式","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":["statement_if","statement_else"],"nodeKind":"container"},{"id":"logic_control_var","name":"设置变量","kind":"native","group":"control","categories":["base_command"],"categorySignatLine truncated
{"schemaVersion":1,"generatedAt":"2026-11-06T05:05:45.453Z","source":{"catalog":"shizai-catalog-data.js","componentsNew":"componentsNew.json"},"counts":{"total":1016,"vendor":997,"native":16,"triggers":3,"fields":4297},"groups":[{"id":"control","label":"编排控制","icon":"branch","count":16},{"id":"trigger","label":"流程触发","icon":"trigger","count":3},{"id":"app","label":"应用与系统","icon":"app","count":60},{"id":"web","label":"网页自动化","icon":"web","count":187},{"id":"office","label":"Office 与文档","icon":"table","count":230},{"id":"data","label":"数据处理","icon":"database","count":167},{"id":"file","label":"文件与传输","icon":"file","count":46},{"id":"desktop","label":"桌面与键鼠","icon":"mouse","count":79},{"id":"vision","label":"图像与 OCR","icon":"scan","count":100},{"id":"message","label":"网络与消息","icon":"send","count":82},{"id":"mobile","label":"移动端与设备","icon":"mobile","count":46}],"components":[{"id":"Trigger.Manual","name":"人工发起","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"由用户在运行页手动发起流程。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.Manual","functionCalls":[],"version":"","tags":["触发","人工发起"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"entryName","label":"入口名称","widget":"text","type":"string","displayType":"text-expression","required":false,"defaultValue":"立即执行","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"Trigger.Schedule","name":"定时触发","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"按工作日或时间计划创建运行请求。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.Schedule","functionCalls":[],"version":"","tags":["触发","定时触发"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"mode","label":"计划方式","widget":"select","type":"string","displayType":"dropDownSelect","required":true,"defaultValue":"工作日","placeholder":"","description":"","advanced":false,"valueTypes":[],"options":["工作日","每天","每周","Cron 表达式"]},{"key":"time","label":"执行时间","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"08:30","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"timezone","label":"时区","widget":"text","type":"string","displayType":"text-expression","required":false,"defaultValue":"Asia/Shanghai","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"Trigger.FileArrival","name":"文件到达触发","kind":"trigger","group":"trigger","categories":["trigger"],"categorySignature":"trigger","description":"监听指定目录的新文件事件。","messageTemplate":"","baseType":"Trigger","functionName":"Trigger.FileArrival","functionCalls":[],"version":"","tags":["触发","文件到达触发"],"platformTag":null,"icon":"trigger","risk":"low","fields":[{"key":"folder","label":"监听目录","widget":"folder","type":"object","displayType":"selectFile","required":true,"defaultValue":"D:\\业务文件\\待处理","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"pattern","label":"文件匹配","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"*.xlsx","placeholder":"","description":"","advanced":false,"valueTypes":[]},{"key":"stableSeconds","label":"文件稳定等待","widget":"number","type":"number","displayType":"number","required":false,"defaultValue":"3","placeholder":"","description":"","advanced":false,"valueTypes":[],"unit":"秒"}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":[]},{"id":"logic_control_if_else","name":"IF 条件分支","kind":"native","group":"control","categories":["judgment"],"categorySignature":"judgment","description":"definition.args1/args3 保存两个 input_statement 子树,代码生成器输出 if/else。","messageTemplate":"","baseType":"logic_control_if_else","functionName":"logic_control_if_else","functionCalls":[],"version":"","tags":["judgment","container"],"platformTag":null,"icon":"branch","risk":"low","fields":[{"key":"condition","label":"条件表达式","widget":"text","type":"string","displayType":"text-expression","required":true,"defaultValue":"","placeholder":"","description":"","advanced":false,"valueTypes":[]}],"output":{"label":"","key":"","suggestedName":"","valueTypes":[],"fields":[]},"slots":["statement_if","statement_else"],"nodeKind":"container"},{"id":"logic_control_var","name":"设置变量","kind":"native","group":"control","categories":["base_command"],"categorySignatLine truncated
+3 -1
查看文件
@@ -75,7 +75,9 @@ describe("应用与系统测试流程包", () => {
expect(issues.every(issue => issue.code === "required"), flow.name).toBe(true);
if (issues.length) blocked.push(flow.id);
}
expect(blocked).toEqual(["test_appsystem_08", "test_appsystem_12"]);
// 12 号(锁屏/解锁)曾经因 Screenlock 的 6 个假必填而阻塞;
// 2026-11-05 参数审计确认实现不读这些字段,已改选填并清空,阻塞随之解除(有意变更)
expect(blocked).toEqual(["test_appsystem_08"]);
const pidOnly = buildSuite()[0].flow;
const killV1 = pidOnly.nodes.find(n => n.componentId === "App_KillV1");
killV1.values.p_name = "";
+3 -1
查看文件
@@ -243,7 +243,9 @@ function normalizeVendorComponent(component, vendorMap) {
// 任务 3.4(决策点 7)+ 五件小事 #1:platformTag 从包内 PLATFORM_GROUPS 单一源派生
// (能力 id 命中包内注册 → win32;未实现的能力与宿主原生节点 → null 跨平台占位)。
const platforms = domainPlatformMap.get(component.id) || component.platforms;
const platformTag = component.platformTag || (platforms?.includes("win32") ? "win32" : null);
// A8 归一化:源值仅接受 win32|darwin|linux 白名单字符串,其余(含上游 shizai 的数字 1 脏值)一律重派生
const sourcePlatform = ["win32", "darwin", "linux"].includes(component.platformTag) ? component.platformTag : null;
const platformTag = sourcePlatform || (platforms?.includes("win32") ? "win32" : null);
return {
id: component.id,
name: component.name,
+14 -15
查看文件
@@ -1,6 +1,7 @@
/**
* rpa-core CLI host(任务 5.1):Node 直接拉起 createEngine() + WindowsDriver,
* 不经过 Electron,验证包的零宿主可运行性。
* rpa-core CLI host(任务 5.1;迁移阶段1-T4 重写):
* Node/Bun 直接拉起 createEngine(),按需显式注册 5 个能力域(不再副作用全量注册),
* 能力调用统一走 engine.execute(ADR-R1 语义真实化),验证包的零宿主可运行性。
*
* 跑通 5 个代表能力(全部只读或落在临时目录,可重复执行):
* 1. 桌面(PowerShell user32):Window_GetActiveWindow —— 读前台窗口标题
@@ -17,12 +18,9 @@ import path from "node:path";
import {
createEngine,
capabilityCount,
getHandler,
registerAll,
type CapabilityHandler,
type RunContext
} from "@rpa/core";
import "@rpa/core/capabilities";
const ctx = {
runId: "rpa-cli",
@@ -37,26 +35,27 @@ const ctx = {
} as unknown as RunContext;
const node = (componentId: string) => ({ id: "rpa-cli", componentId, position: { x: 0, y: 0 }, values: {}, options: {} });
async function run(name: string, handler: CapabilityHandler, args: Record<string, unknown>): Promise<void> {
async function run(engine: import("@rpa/core").RpaEngine, name: string, args: Record<string, unknown>): Promise<void> {
process.stdout.write(`[${name}] `);
const result = await handler(ctx, node(name), args, undefined as never);
const result = await engine.execute(name, args, ctx, node(name));
console.log("OK ->", typeof result === "string" ? result.slice(0, 120) : JSON.stringify(result)?.slice(0, 200));
}
async function main(): Promise<void> {
const engine = createEngine();
await registerAll(engine);
console.log(`rpa-core CLI host:引擎已创建,注册能力 ${capabilityCount()} 个\n`);
// 显式注册本 CLI 用到的 5 个能力域(迁移后 Agent-Employee 侧同样按域注册)
await registerAll(engine, ["desktop", "commonFinal", "excelCommon", "files", "http"]);
console.log(`rpa-core CLI host:引擎已创建(注入 driver 可用 createEngine({ driver })),注册能力 ${capabilityCount()} 个\n`);
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "rpa-cli-"));
try {
await run("Window_GetActiveWindow", getHandler("Window_GetActiveWindow")!, {});
await run("Window_GetScreenSize", getHandler("Window_GetScreenSize")!, {});
await run("Excel_Cs_NewV5", getHandler("Excel_Cs_NewV5")!, { filePath: tmpDir, name: "rpa-cli-demo.xlsx" });
await run(engine, "Window_GetActiveWindow", {});
await run(engine, "Window_GetScreenSize", {});
await run(engine, "Excel_Cs_NewV5", { filePath: tmpDir, name: "rpa-cli-demo.xlsx" });
const file = path.join(tmpDir, "hello.txt");
await run("File_Write", getHandler("File_Write")!, { path: file, content: "hello from rpa-core CLI" });
await run("File_Read", getHandler("File_Read")!, { path: file });
await run("Http_Get", getHandler("Http_Get")!, { url: "https://www.baidu.com", timeout: 15 });
await run(engine, "File_Write", { path: file, content: "hello from rpa-core CLI" });
await run(engine, "File_Read", { path: file });
await run(engine, "Http_Get", { url: "https://www.baidu.com", timeout: 15 });
console.log("\n5 项代表能力全部通过 ✓");
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
+18
查看文件
@@ -0,0 +1,18 @@
import { RuntimeError } from "@rpa/core";
/**
* 浏览器能力统一的元素选择器入参解析(W3 定位器统一修复)。
*
* 两个浏览器能力文件(browser.ts / browser-extended.ts)此前各持一份 selectorOf,
* 兜底键集合不一致(extended 缺 uiWindowSelector),同一流程里同名参数在
* 两个能力下行为不同。现收敛为单一事实源,键集合为两者并集。
*/
export function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
+1 -8
查看文件
@@ -1,5 +1,6 @@
import path from "node:path";
import { browserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import { register } from "@rpa/core";
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
@@ -21,14 +22,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser;
}
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", { recommendation: "请拾取或填写网页元素" });
}
return selector;
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
}
+1 -10
查看文件
@@ -1,4 +1,5 @@
import { browserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import { register } from "@rpa/core";
import { RuntimeError, type BrowserLike, type RunContext } from "@rpa/core";
@@ -24,16 +25,6 @@ async function session(ctx: RunContext): Promise<BrowserLike> {
return ctx.browser;
}
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
function enabled(value: unknown): boolean {
return value === true || value === 1 || String(value).toLowerCase() === "true" || String(value) === "1";
}
+1 -10
查看文件
@@ -1,6 +1,7 @@
import { register } from "@rpa/core";
import { RuntimeError } from "@rpa/core";
import { browserSession, externalBrowserSession } from "../browser/session";
import { selectorOf } from "../browser/selector";
import type { BrowserLike, RunContext } from "@rpa/core";
async function session(ctx: RunContext): Promise<BrowserLike> {
@@ -63,16 +64,6 @@ register(["WebBrowser_CreateV3", "WebBrowser_CreateV2", "WebBrowser_Create", "We
return { url: await browser.currentUrl(), title: "" };
});
function selectorOf(args: Record<string, unknown>): unknown {
const selector = args.uiSelector ?? args.selector ?? args.target ?? args.uiWindowSelector;
if (selector === undefined || selector === null || selector === "") {
throw new RuntimeError("validation_error", "元素选择器为空", {
recommendation: "填写 css:#id、text:提交、aria:button=提交 或使用拾取器"
});
}
return selector;
}
register(["WebBrowser_GoUrl", "WebBrowser_Navigate"], async (ctx, node, args) => {
const browser = await session(ctx);
const url = String(args.url ?? "");
+35 -3
查看文件
@@ -1,4 +1,4 @@
import { app, BrowserWindow, dialog, ipcMain, screen, shell } from "electron";
import { app, BrowserWindow, desktopCapturer, dialog, ipcMain, screen, shell } from "electron";
import path from "node:path";
import type { AppInfo, AppSettings, FlowDefinition, FlowSaveOptions, FlowSaveResult, FlowSummary, FlowsImportResult } from "../shared/types";
import {
@@ -24,7 +24,38 @@ import { logMain } from "./logger";
// 子流程仓库在宿主(flows.ts),包内能力经 host-bridge 取用(阶段 2 正式化 HostBridge)
setSubflowSource({ listFlows: async () => listFlows(), getFlow: async (id) => getFlow(id) });
// 宿主能力桥(决策点 4 最小面):人机交互窗口工厂 / DPI 坐标换算 / AI 配置读取
// 宿主屏幕服务(迁移阶段1-A9):视觉能力经宿主桥拿 desktopCapturer/screen,Core 零 Electron 依赖
const hostScreenService = {
listDisplays: () =>
screen.getAllDisplays().map((display) => ({
id: String(display.id),
bounds: display.bounds,
scaleFactor: display.scaleFactor || 1
})),
primaryDisplay: () => {
const display = screen.getPrimaryDisplay();
return { id: String(display.id), bounds: display.bounds, scaleFactor: display.scaleFactor || 1 };
},
dipToScreenPoint: (point: { x: number; y: number }) => screen.dipToScreenPoint(point),
displayMatching: (rect: { x: number; y: number; width: number; height: number }) => {
const display = screen.getDisplayMatching(rect);
return { id: String(display.id), bounds: display.bounds, scaleFactor: display.scaleFactor || 1 };
},
captureDisplay: async (displayId: string, wantWidth: number, wantHeight: number): Promise<Uint8Array> => {
const sources = await desktopCapturer.getSources({
types: ["screen"],
thumbnailSize: { width: wantWidth, height: wantHeight }
});
const source =
sources.find((item) => item.display_id === displayId) ||
sources.find((item) => item.thumbnail.getSize().width === wantWidth) ||
sources[0];
if (!source || source.thumbnail.isEmpty()) throw new Error(`无法获取显示器 ${displayId} 的屏幕截图`);
return source.thumbnail.toPNG();
}
};
// 宿主能力桥(决策点 4 最小面):人机交互窗口工厂 / DPI 坐标换算 / AI 配置读取 / 屏幕服务
setHostBridge({
createFormWindow: ({ title }) => {
const win = new BrowserWindow({
@@ -46,7 +77,8 @@ setHostBridge({
loadAiConfig: () => {
const ai = loadSettings().aiConfig;
return { baseUrl: ai.baseUrl ?? "", apiKey: ai.apiKey ?? "", model: ai.model ?? "", visionModel: ai.visionModel ?? "" };
}
},
screen: hostScreenService
});
let mainWindow: BrowserWindow | null = null;