Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f5e8bcb0e | ||
|
|
594377ab79 | ||
|
|
41975baf5a | ||
|
|
08e798c087 | ||
|
|
43ae705629 | ||
|
|
10d125ce9d | ||
|
|
b4c89fd7d3 | ||
|
|
751dc04713 | ||
|
|
7a553fb270 | ||
|
|
4f1fbc07dc | ||
|
|
62652b4fca | ||
|
|
a766902a18 | ||
|
|
81ce4bc9b8 | ||
|
|
3a0ff42ac6 | ||
|
|
fb6e8ca0bf |
No files matched your search
@@ -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(测试迁移+两处测试修复)
|
||||
@@ -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 完成即"接入就绪" |
|
||||
@@ -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] 已按第五条线工作流完成自审,等待主控审核和合并
|
||||
@@ -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. 本指南边界
|
||||
|
||||
@@ -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:// 内部页 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -185,6 +185,8 @@ export interface RunEvent {
|
||||
checkpointId?: string;
|
||||
idempotencyKey?: string;
|
||||
resumable?: boolean;
|
||||
// 双端透明调度(ADR-R6 补记):本次执行驻地;用户界面不展示,审计/排障必须可见
|
||||
executor?: "local" | "backend";
|
||||
startedAt?: string;
|
||||
nodeLabel?: string;
|
||||
total?: number;
|
||||
|
||||
|
Before Width: | Height: | Size: 5.4 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
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")
|
||||
@@ -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,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,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,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 ?? "");
|
||||
|
||||