Compare commits

15 Commits
Author SHA1 Message Date
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
56 changed files with 542 additions and 252 deletions

No files matched your search

+17
View File
@@ -69,3 +69,20 @@
- 发现陷阱: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(测试迁移+两处测试修复)
+79
View File
@@ -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
View File
@@ -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] 已按第五条线工作流完成自审,等待主控审核和合并
+47 -4
View File
@@ -215,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 执行契约
@@ -342,18 +344,59 @@
| 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 分组加载,不维护两份代码 |
| 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 |
| 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;对迁包零影响 |
### 待对面对确认(仅剩小问题,不猜测)
| Q# | 问题 | 评审要点 |
|---|---|---|
| Q1 | CapabilityDefinition 是否需要 platform 字段(对面清单未见) | 建议对面补字段或约定由 catalog platformTag 承载 |
| Q2 | utilityExtended(命令执行)对应哪个 PermissionScope | 13 项中 desktop.process 是候选,需对面确认语义(backend 无桌面,进程执行属哪域) |
| 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 入审计链的取用点。
---
## 11. 本指南边界
+36 -17
View File
@@ -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
View File
@@ -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);
}
}
+9 -5
View File
@@ -255,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;
}
@@ -370,6 +370,8 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
evidence: state.evidence,
checkpointId: node.id,
resumable,
executor: "local",
attempt: state.attempt,
...(resumable ? { idempotencyKey: `${ctx.runId}:${node.id}` } : {})
});
return result;
@@ -378,7 +380,7 @@ 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);
@@ -394,7 +396,8 @@ async function executeNode(node: FlowNode, ctx: RunContext, states: Map<string,
status: "cancelled",
error: runtimeError,
durationMs: state.durationMs,
evidence: []
evidence: [],
attempt: state.attempt
});
throw error;
}
@@ -411,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;
+2
View File
@@ -185,6 +185,8 @@ export interface RunEvent {
checkpointId?: string;
idempotencyKey?: string;
resumable?: boolean;
// 双端透明调度(ADR-R6 补记):本次执行驻地;用户界面不展示,审计/排障必须可见
executor?: "local" | "backend";
startedAt?: string;
nodeLabel?: string;
total?: number;
File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

File renamed without changes.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 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);
});
@@ -18,9 +18,9 @@ vi.mock("@gutenye/ocr-node", () => ({
default: { create: vi.fn(async () => ({ detect: detectMock })) }
}));
import { getHandler } from "../engine/registry";
import { setHostBridge, type HostDisplay } from "../engine/host-bridge";
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,
@@ -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;
@@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode, RunEvent } from "../types";
import { register } from "./registry";
import { NODE_STATUS_TO_STEP_STATUS, toStepStatus } from "./types";
import { runFlow } from "./runtime";
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];
@@ -87,6 +87,10 @@ describe("A++ 检查点事件负载", () => {
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");
@@ -109,4 +113,30 @@ describe("A++ 检查点事件负载", () => {
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);
});
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest";
import type { AutomationDriver } from "../driver/types";
import { createEngine } from "./engine";
import { WindowsDriver } from "../driver/windows/windows-driver";
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 且实例缓存", () => {
@@ -1,9 +1,9 @@
import { describe, expect, it } from "vitest";
import type { CatalogComponent, FlowDefinition, FlowNode } from "../types";
import { validateFlowGraph, hasBlockingIssues } from "./graph-validator";
import { register } from "./registry";
import { RuntimeError } from "./types";
import { runFlow } from "./runtime";
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];
@@ -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")
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 ?? "");