Compare commits

...

41 Commits

Author SHA1 Message Date
05764cac15 docs: add Action label naming guidance in ACTIONS.md (v1.0.96)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 11:52:41 +08:00
0a6b0171d8 docs: settle work item lifecycle contract (v1.0.95)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-21 16:30:34 +08:00
43486afec4 docs: dedupe changelog after lookup.multiple contract (v1.0.94)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-21 16:20:53 +08:00
225d5c0777 docs: add lookup.multiple multi-select contract (v1.0.93)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-21 16:19:13 +08:00
fbf326d525 Document replaces:import and add CRUD/import action examples
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-21 11:57:38 +08:00
28c3c8d8ab Release v1.0.91: document no SQLite FK between business tables
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-08-19 16:03:42 +08:00
571c135114 Release v1.0.90: document grid_help data-management contract
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-18 11:00:20 +08:00
d840745758 chore: add MIT license, SECURITY.md, and placeholder developer_ids
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:44:56 +08:00
cf3f0ae990 fix: default git tag message from UTF-8 bytes for Windows PowerShell 5.1
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:19:47 +08:00
a11cdf4e7e fix: write git tag/commit messages via UTF-8 -F file
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:16:33 +08:00
81096051dd fix: invoke git without cmd.exe so annotated tags keep UTF-8
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-18 10:13:28 +08:00
f221f81d4e docs: add PRODUCT_SURFACES peer entry contract for skill authors
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-17 18:49:05 +08:00
3738f0bea2 docs: 沉淀 Action 表单 visibleWhen 条件显隐契约
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-16 14:26:56 +08:00
6ebc570632 docs: precipitate Action/CRUD Lookup contract for skill authors
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-16 12:58:39 +08:00
2d02ccff5d Release v1.0.82: add optional hoverHint contract for toolbar skill actions
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-15 10:02:27 +08:00
25b0c62dea fix: drop default task_logs; process in log, progress in task center (v1.0.81)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-14 20:53:25 +08:00
ff75aefcdd docs: precipitate HTML report contract for data management
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-14 15:52:06 +08:00
49e4d6e9a6 docs: default visible columns contract (v1.0.79)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
沉淀 _jiangchang_tables.default_visible_columns_json:技能声明默认展示列;未配置全显;用户改「列」后本机覆盖;与 visible=0 硬隐藏区分。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 10:17:17 +08:00
b27dd5b5ba docs(rpa): mandate poll_until and raise kit min to 1.2.11 (v1.0.78)
All checks were successful
技能自动化发布 / release (push) Successful in 4s
Backport bounded readiness-wait convention and thin env/readiness error codes from platform-kit 1.2.11 into template docs, examples, and default platform_kit_min_version.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-12 10:47:15 +08:00
9fa5953713 docs: form field type contract Action/CRUD symmetry (v1.0.77)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-11 15:52:27 +08:00
db2325dbe4 docs: contract for CRUD form description and SQLite DEFAULT prefill (v1.0.76)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-11 14:38:32 +08:00
16ee121ab3 Release v1.0.75: cell presentation contract for data management
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-10 14:31:04 +08:00
139d176841 Release v1.0.74: hostEffects/steps Action contract
All checks were successful
技能自动化发布 / release (push) Successful in 12s
2026-08-10 12:05:58 +08:00
0b6bf0b2fa Release v1.0.73: Data Policy contract and schemas
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-09 10:53:31 +08:00
51be1517c0 docs: clarify row placement as row-level shell including context menu
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-09 10:20:20 +08:00
98f057e26b Release v1.0.71: sync timeout docs 60s
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-08-08 20:02:07 +08:00
8878a90315 docs: align toolbar button layout with host (no more-menu)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-08 19:32:46 +08:00
2ecb1172f4 Release v1.0.69: input/output data dirs
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-08 19:28:31 +08:00
d2c8c3240c docs: document data-management skill button order via actions array
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-08-08 18:52:39 +08:00
2c9f610a8e chore: bump version to 1.0.67
All checks were successful
技能自动化发布 / release (push) Successful in 13s
2026-08-07 11:25:03 +08:00
2c9bf56054 chore: remove tools/ui_inspector; point docs to jiangchang-ui-recorder.
Product lives at https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder; prior commit keeps an archive snapshot.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:20:23 +08:00
633ecff4e2 chore: archive tools/ui_inspector snapshot before migration removal.
Keep the last in-template copy on main so history can restore it; product continues in jiangchang-ui-recorder.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:19:14 +08:00
46bc573777 Release v1.0.66: table default_sort_json for data management
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-08-02 12:17:55 +08:00
0002e86729 docs: columnMapping for replaces create/update lookup reuse
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 15:14:44 +08:00
caf0191366 feat: document optional replaces for CRUD mutation binding
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 14:43:15 +08:00
ba284922e5 docs: data-management column lookup via options_json
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-31 13:37:51 +08:00
7cf139dfd2 docs: Skill Action interaction direct/confirm/form
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-31 11:15:51 +08:00
6c1ffc73d3 Release v1.0.61: desktop RPA Name/Value and activate fixes
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-07-25 17:02:07 +08:00
8fa7ec2743 Release v1.0.60: harden business logging policies (LOGGING-006/007/008)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-07-25 11:23:32 +08:00
79d21c063d feat: add three-surface RPA locator standards and examples
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-25 10:20:45 +08:00
c15e23e0d4 docs: 沉淀 account-manager 多账号与 Profile 工业标准
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-07-24 16:50:57 +08:00
104 changed files with 7365 additions and 1029 deletions

View File

@@ -31,23 +31,24 @@ STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
# 浏览器启动超时(可选):限制 launch_persistent_browser 最长时间;空=不限制(默认)
# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器/限频时可开启并调大
# 以下为可选目录配置。一般不用改;取消注释并填写后,才覆盖默认子文件夹。
# 填写相对路径时,相对于本技能的数据目录(不是当前工作目录)。
# 用户可见只有 input(上行)与 output(下行);见 development/DATA_PATHS.md。
# 下载目录:技能保存下载文件的位置
# SKILL_DOWNLOAD_DIR=downloads # 相对本技能数据目录;留空或保持注释则用默认 downloads
# 输入目录:用户/Agent 放入的待处理文件(清单、CSV、产品表等)
# SKILL_INPUT_DIR=input # 相对本技能数据目录;留空或保持注释则用默认 input
# 导入目录:待导入文件放置的位置
# SKILL_IMPORT_DIR=imports # 相对本技能数据目录;留空或保持注释则用默认 imports
# 输出目录:技能/宿主交给用户拿走的结果(视频、报表、导出文件等)
# SKILL_OUTPUT_DIR=output # 相对本技能数据目录;留空或保持注释则用默认 output
# 导出目录:技能导出文件的保存位置
# SKILL_EXPORT_DIR=exports # 相对本技能数据目录;留空或保持注释则用默认 exports
# 上传目录:待上传文件放置的位置
# 上传目录:待发布到外部平台的 staging(可选,不上侧栏)
# SKILL_UPLOAD_DIR=uploads # 相对本技能数据目录;留空或保持注释则用默认 uploads
# 缓存目录:临时缓存文件的位置
# 缓存目录:临时缓存文件的位置(不上侧栏)
# SKILL_CACHE_DIR=cache # 相对本技能数据目录;留空或保持注释则用默认 cache
# 临时目录:运行中临时文件的位置
# 临时目录:运行中临时文件的位置(不上侧栏)
# SKILL_TEMP_DIR=temp # 相对本技能数据目录;留空或保持注释则用默认 temp

View File

@@ -9,6 +9,232 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 1.0.96
- 在 `references/ACTIONS.md` 沉淀数据管理技能按钮中文名(`label`)参考表:同类通用动作用同一动词,不是白名单
- 打开类改为「打开目录 / 打开浏览器」,一点即开、成功不弹「执行成功」;示例不再使用「打开文件位置」
- 交活 Checklist A7-7;POLICY_MATRIX 软规则:不扫描 label 关键字
## 1.0.95
- 正式沉淀「业务行生命周期」通用契约(`references/WORK_ITEM_STATUS.md`):用可操作/进行中/成功·失败·跳过类终态等**角色**约束处理队列,不绑死某一技能的状态名
- 默认只处理可操作态;开始抢占进行中;结束回写成功或失败(失败带原因);重做靠人工改回可操作
- REQUIREMENTS §0.3 状态→角色映射;CHECKLIST S0-11 / A6-7~A6-9;SKILL / ACTIONS / SCHEMA / references 索引交叉引用
## 1.0.94
- 修正 1.0.93 更新日志重复小节
## 1.0.93
- 沉淀 Lookup **多选**契约:同一 `lookup` / `options_json` 对象上加 `multiple: true`(平行扩展单选,不另造 display_type)
- 多选存 `TEXT` 逗号分隔 id;列表展示映射为多个 label;控件由宿主按选项数量选择(短列表勾选 / 长列表 Chip+下拉)
- 更新 SCHEMA / ACTIONS / skill-actions.schema / lookup 示例与 options_json 校验
## 1.0.92
- 沉淀数据管理 `replaces: import`:导入 UI 统一宿主面板,确认后由技能写库
- ACTIONS / schema / 守护测试对齐 create·update·delete·import;补充可抄示例 `replaces-*.action.example.json`
- 修正旧文档「宿主导入仍直写」的过时说明
## 1.0.91
- 沉淀「表与表之间禁止 SQLite 外键」契约:只存业务 id,关联与级联在 repository/service;界面用 `lookup`
- `display_metadata_validator` 扫描业务表 DDL,含 `FOREIGN KEY` / `REFERENCES` 时报错
- 交活 Checklist 新增 A6-6;`lookup` 文档用语改为「跨表引用 id」,避免与 SQLite 外键混淆
## 1.0.90
- 沉淀数据管理表格说明契约:`_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help`
- 有内容才显示表上方说明和列头「?」;不写则与今日一致
- 与新增/编辑表单的 `description` 独立,禁止互拷(见 `references/SCHEMA.md`)
## 1.0.89
- 仓库增加 MIT 许可证,版权归属深圳匠厂科技有限公司
- 增加 SECURITY.md:未公开漏洞请通过匠厂官网联系,不要发在公开 Issue
- 模板 developer_ids 改为占位 12345,复制后请换成自己的匠厂用户 ID
## 1.0.88
- `release.ps1` 默认 tag 说明改为 UTF-8 字节写入,不再依赖脚本源码里的中文默认参数(Windows PowerShell 5.1 会把无 BOM 的 UTF-8 按系统代码页解析)
- 不改写已发布 tag(v1.0.86 / v1.0.87 说明仍可能乱码)
## 1.0.87
- `release.ps1` 打 annotated tag / 自动 commit 改为 UTF-8 消息文件 + `git -F`,避免 `-m` 走控制台代码页
- 默认说明仍可能乱码(见 1.0.88)
## 1.0.86
- 去掉 `release.ps1` 里通过 `cmd /c` 调 git 的写法;中文 tag 说明在部分 Windows 控制台代码页下仍可能乱码,见 1.0.87
## 1.0.85
- 新增开发主文 `development/PRODUCT_SURFACES.md`:说明对话 / 数据管理 / 定时任务平级关系、何时走哪个入口、如何做入口定位
- 需求与交活清单增加入口定位表;纠正「只做对话即可」与「Action 一概可选」的误导语气
- 数据管理明确为 UI 执行面 + 过程可见,与对话同内核
## 1.0.84
- 沉淀 Action 参数表单字段条件显隐契约 `visibleWhen`(先选模式再出对应参数)
- 文档入口:`ACTIONS.md` / `SKILL_ACTION_RUNTIME.md` / `CHECKLIST` A4-7;与 Policy L3 行态 `visibleWhen` 消歧
- 新增示例 `assets/examples/visible-when-action.example.json`;Schema 已支持该字段
## 1.0.83
- 沉淀跨表 Lookup 契约:Action 可用内联 `lookup` 或绑列下拉,禁止让用户手填内部编号
- 统一展示:`编号 · 名称`,可选 `extra_column` 时为 `编号 · 名称(额外)`
- 更新 `ACTIONS.md` / `SCHEMA.md` / `skill-actions.schema.json`、示例与验收清单
## 1.0.82
- 沉淀数据管理技能按钮可选悬停字段 `hoverHint`:短名看不懂或会覆盖数据时再写;不回退说明或确认黄条
- 宿主内置删除/刷新/新增/导入/导出不走该字段;默认 health/version/config-path 不加悬停
## 1.0.81
- 模板默认不再创建或写入 `task_logs`:过程看运行日志,进度看任务中心,业务结果看业务表
- `run` 录屏信息并入 `finish()`;去掉 CLI `logs` / `log-get`
- 已安装技能的旧库不受影响
## 1.0.80
- 沉淀数据管理「HTML 页面报告」契约:写入 `{skill_data}/html/*.html`,重新生成 Action id 固定为 `render-html`
- 新增 `get_html_dir()`、薄写入 helper(`html_page_service`)、示例页与 Action 示例 JSON
- 文档:`ACTIONS.md` / `DATA_PATHS.md` / `SKILL_ACTION_RUNTIME.md` 等已区分 HTML 报告与 `export-*` / 宿主表导出
## 1.0.79
- 沉淀数据管理表级「默认展示列」契约:`_jiangchang_tables.default_visible_columns_json`
- 已配置则宿主默认只显示名单列;未配置全显;用户改过「列」后以本机偏好为准
- 与 `_jiangchang_columns.visible=0` 硬隐藏区分:默认隐藏仍可在「列」面板勾回
- `upsert_table_metadata` / 旧库幂等 `ALTER` 已支持该字段(见 `references/SCHEMA.md`)
## 1.0.78
- 反哺 RPA 有界就绪等待约定:状态等待统一用 kit `poll_until`(需 `jiangchang-platform-kit>=1.2.11`)
- 错误码表补充 `UI_NOT_READY` / `BROWSER_START_TIMEOUT` / `ENV_DEGRADED`,明确慢机器勿误判为配置错或频控
- 模板默认 `platform_kit_min_version` 上调至 `1.2.11`;`.env.example` 增加可选 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`
- `examples/real_browser_rpa` 搜索结果等待改为 `poll_until` 示例写法
## 1.0.77
- 固化 Action / CRUD **对称**表单字段类型契约:text、textarea/json、number、boolean、enum/lookup、sensitive、date、datetime_unix_seconds、url/email(薄层)
- 明确可编辑业务时间用 Unix **秒**读写;审计 `created_at`/`updated_at` 仍只读展示
- 明确本版**不做**文件上传、多选/标签数组、复杂 nested 表单,避免宿主反复扩面
- 更新 `SCHEMA.md`、`ACTIONS.md`、`skill-actions.schema.json`(`display_type`/`format`)
## 1.0.76
- 固化数据管理「新增/编辑」表单契约:列 `description` 须在标签下展示;创建默认值取自 SQLite 列 `DEFAULT`(见 `references/SCHEMA.md`)
- 明确禁止把说明塞进 `display_name`;与 Skill Action 的 `inputSchema.description` / `default` 分工写清(见 `ACTIONS.md`)
- 宿主须按该契约实现下发与预填;技能侧用元数据说明 + DDL DEFAULT 即可,第一期不新增表单默认值元数据列
## 1.0.75
- 正式沉淀数据管理「单元格呈现」通用契约(`references/CELL_PRESENTATION.md`):技能声明某表某行某列的语义 tone,宿主映射主题样式
- 用途不限(超时未填、库存预警、状态异常、审核驳回等);推荐旁路表 `_jiangchang_cell_presentation`;填完后重算即取消强调
- 新增 Schema / 示例;`actions.json` 可选顶层 `cellPresentation.enabled`
## 1.0.74
- 沉淀宿主 Skill Action 编排契约:`hostEffects`(`onSuccess` / `onFailure`)与 `steps`(`before` / `afterSuccess` / `afterFailure`)
- 支持成功后 `reloadTable` 等 UI 副作用与级联删除步骤;需匠厂宿主 ≥2.8.5;省略则不自动刷表
- 更新 `skill-actions.schema.json`、ACTIONS.md、SKILL_ACTION_RUNTIME
## 1.0.73
- 正式发布 Data Policy 契约(`references/POLICY.md` v1.0):可选身份授权,默认 L0 零配置
- 新增 Snapshot Schema 与示例;`actions.json` 支持顶层 `policy` 与 Action 可选 `authz` 提示
- ACTIONS 扩展为六职责正交(含 policy)
## 1.0.72
- 澄清 `placements: "row"` 契约:表示数据管理**行级入口**;技能配置决定菜单项,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action,无独立菜单 DSL / `row-context-menu` placement)
- 更新 ACTIONS.md、SKILL_ACTION_RUNTIME、DEVELOPMENT 验收表与 skill-actions.schema 描述
## 1.0.71
- 文档对齐:Skill Action `sync` 默认超时约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`);接近或超过约 60s 的任务应标 `async`
- 更新 `SKILL_ACTION_RUNTIME.md`
## 1.0.70
- 对齐宿主:数据管理表顶栏技能按钮全部直接展示,取消「更多操作」溢出菜单
- 补充宽窄布局说明:够宽左右横排一行;不够宽左右分栏、栏内竖排
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.69
- 用户数据目录收敛为上行 `input/` 与下行 `output/`(取代 downloads / imports / exports 三分法)
- `runtime_paths` 提供 `get_input_dir()` / `get_output_dir()`;相对输入路径解析到 `input/`
- 更新 DATA_PATHS.md、CONFIG、`.env.example`;缓存 / 临时 / RPA 录屏目录仍为内部目录
## 1.0.68
- 明确数据管理技能按钮顺序契约:同一 placement(toolbar / row / batch)下按 `actions` 数组相对顺序展示;调整数组先后即可改序
- 说明宿主内置刷新 / 导出 / 查看等不参与该顺序;无独立 `sortOrder` 字段
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.67
- 移除模板内嵌的 `tools/ui_inspector`;UI 录制器产品迁至独立仓库 `jiangchang-ui-recorder`
- 相关开发文档改为指向 Gitea 产品仓库入口
## 1.0.66
- 数据管理支持表级默认排序:`_jiangchang_tables.default_sort_json`(多字段 JSON);宿主优先采用,未配置则仍按 updated_at → created_at → 主键
- 更新 SCHEMA.md 与 `upsert_table_metadata`;旧库幂等补列
## 1.0.65
- `replaces` create/update 可复用表列 lookup:字段名对齐列名,或使用 `bind.columnMapping` / 字段 `column`
- 更新 ACTIONS.md 与 skill-actions.schema.json
## 1.0.64
- Skill Action 新增可选 `replaces: create|update|delete`:接管数据管理默认新增/编辑/删除,无配置时宿主仍直写 SQLite
- 更新 ACTIONS.md 与 skill-actions.schema.json;`replaces` 须配合 `bind.tables`,可与空 `placements` 并用
## 1.0.63
- 数据管理支持字段级 lookup:`options_json` 可声明同库关联表(`ref_table` / `value_column` / `label_column`),宿主新建/编辑时渲染下拉
- 更新 SCHEMA.md 与元数据校验,兼容静态枚举数组与 lookup 对象两种形态
## 1.0.62
- Skill Action 新增 `interaction` / `interactionByPlacement`:声明数据管理点击是直通、轻确认还是参数表单(与 `executionProfile` 正交)
- 明确 `confirmation` 只负责风险文案;cron 触发与 agent 忽略 `interaction`
- 补充 ACTIONS.md 示例(打开文件位置 = direct,打开浏览器 = confirm)
## 1.0.61
- 桌面 RPA 标准反哺:明确 Name≠Value、主窗最大化与禁止误 `SW_RESTORE`、同名叶子须绑父容器
- 更新 `real_desktop_rpa` example:activate/Value 定位、element-map 补全「新的客户/添加」、README 与契约测试
## 1.0.60
- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print
- 新增 hard 门禁 POLICY-LOGGING-006/007/008(域模块有日志、RPA 事件词、ERROR 与 logger 共现)
- 模板金样 `task_service` 示范 `entitlement_ok`、`rpa_start`/`stage=`、`task_failed error_code=`
- 增加 `tests/samples/test_logging_contract.py.sample` 与交活 Checklist 日志 P0 项
## 1.0.59
- 新增浏览器、Windows 桌面、Android/iOS 三端元素拾取与定位标准,要求先真实拾取、填写 element map、验证唯一性和业务后置状态
- 从已跑通的企业微信添加客户技能提炼真实 Windows UIA example,提供窗口唯一性、作用域定位、probe 和 dry-run/assisted 范式
- 手机 RPA 增加设备、Appium Inspector、Native/WebView、控件树和失败留证标准;在成功案例补齐前明确标为规格骨架
- 需求模板和交活 Checklist 增加禁止 AI 猜 selector、禁止 first/index 掩盖歧义、结构变化停止留证等门禁
## 1.0.58
- 新增 account-manager 接入标准,说明一账号一 Profile、多账号矩阵、登录状态持久化与完整运行生命周期
- 明确账号创建后的查看位置:匠厂数据管理、account-manager CLI、本地用户数据目录及 Profile 目录
- 强制所有账号选择路径取得租约并在异常出口释放,修复模板中指定数字账号 ID 时缺少租约的问题
- 增加关闭重开保持登录、多账号隔离、租约冲突、异常释放和自助排障验收项
- 新增机器门禁,防止后续模板退回到裸浏览器、空租约或只做单账号冒烟测试
## 1.0.57
- 新增 AI + RPA 技能开发总纲,明确 Agent 负责理解与编排、RPA 负责浏览器 / 桌面 / 手机的确定性执行

21
LICENSE Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 深圳匠厂科技有限公司
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -41,6 +41,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
说明用户在使用前要准备的东西,例如:
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
- 相关业务权限(查询、导出、提交等)已具备
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
@@ -100,3 +101,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
### 结果文件在哪里查看?
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
### 已经登记的账号在哪里查看?
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。

20
SECURITY.md Normal file
View File

@@ -0,0 +1,20 @@
# 安全漏洞披露
本仓库是匠厂技能开发模板。请把**未公开的安全漏洞**发给深圳匠厂科技有限公司,不要在公开 Issue、PR 或讨论区里贴完整复现步骤、PoC 或利用细节。
## 怎么报告
请通过匠厂官网联系渠道提交:[https://www.jc2009.com/](https://www.jc2009.com/)
建议在联系时注明:
- 标题或开头带 `[Security]`
- 影响的仓库 / 版本(例如 `skill-template`、tag `v1.0.x`)
- 问题类型、影响范围、复现步骤
- 若已有修复思路,可一并说明
我们会确认收悉,并在有结论后告知是否修复、是否公开披露。
## 不在本页范围
本页只说明**如何向我们报告漏洞**。技能运行时的权限、RPA 合规、加密发布等产品安全设计,见 `development/` 与 `references/`,不要写进本文件。

View File

@@ -1,17 +1,16 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.57
version: 1.0.96
author: 深圳匠厂科技有限公司
metadata:
openclaw:
slug: your-skill-slug
platform_kit_min_version: "1.2.2"
platform_kit_min_version: "1.2.11"
emoji: "📦"
category: "通用"
developer_ids:
- 10032
- 12428
- 12345 # 占位,请换成你的匠厂用户 ID
allowed-tools:
- bash
---
@@ -27,22 +26,27 @@ allowed-tools:
- **Agent 是入口与编排者**:理解用户意图、补齐参数、判断风险、选择 Action,并用业务语言解释结果。
- **RPA 是确定性执行器**:只按已验证步骤操作授权范围内的浏览器、桌面软件或手机;不得破解、逆向、调用未授权私有协议或绕过验证码、安全验证和平台限制。
- 遇到验证码、人脸、U盾、频控、账号风险或权限不足时,转人工或安全停止,禁止自动硬闯、换号或无限重试。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`,三端执行细则再读 `development/RPA.md`。
- 开发者与 AI 编程工具先读 `development/AI_RPA.md`;三端界面代码必须先读 `development/RPA_ELEMENT_LOCATING.md` 并完成真实拾取与 element map,再按浏览器/桌面/手机选读 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`;浏览器账号、Profile、多账号和租约另读 `development/ACCOUNT_MANAGER.md`。
### 架构铁律(先读)
- **宿主产品入口平级**:对话任务 / 数据管理 / 定时任务是同一技能能力的不同触发场景,**同等重要**;挂哪些由本技能 UX 定义(入口定位表),详见 `development/PRODUCT_SURFACES.md`。以后宿主若增加新入口,仍是「新触发场景 + 同一内核」。
- **单业务内核、多入口**:数据管理 / 定时任务 / Agent / 技能详情 / 直接 CLI 必须复用同一 domain service;禁止按入口复制业务逻辑,禁止按 `source` 分叉业务行为。
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。
- **数据管理**:既是 **UI 执行面**(点按钮走与对话相同的闭环),也是数据与过程可见;不是「只读库」。
- **`placements`**:只决定 Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail)。其中 `row` 表示**行级入口**(宿主可呈现为操作列、行右键、行内「⋯」等;技能配什么就显示什么,无独立菜单 DSL)。数据管理技能按钮顺序见 `actions` **数组相对顺序**(详见 `references/ACTIONS.md`),无独立 sortOrder。
- **`bind.tables`**:只决定数据管理出现在哪些表;含 `toolbar` 时**必须**显式非空绑定。
- **`executionProfile`**:只决定执行方式——`sync` 当场返回结果,`async` 进**任务中心**。与 placements / 入口**无关**。心智上多数为 sync,仅长任务 / RPA 标 async;宿主省略该字段时按 sync。每个 Action **仍必须**显式写 `sync` 或 `async`。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;特殊业务报告才另做 `export-*`。
- **`policy`(可选)**:需要按用户/角色控制数据管理按钮或行列时,在 `actions.json` 顶层启用 Data Policy,由技能 `policy` CLI 输出 Snapshot;**未启用 = 与今日一致(L0)**。详见 `references/POLICY.md`。用户/角色数据源(飞书/库/API)属技能内部,宿主不托管。
- **`cellPresentation`(可选)**:需要数据管理「某格特殊标色/强调」时启用;技能按当前数据写入呈现(语义 tone),宿主映射主题样式;**未启用 = P0 无标色**。用途不限(超时、预警、驳回等)。详见 `references/CELL_PRESENTATION.md`。
- **业务行生命周期(有处理队列时)**:对某一行做生成/触达/私信等处理时,默认只操作「可操作」角色状态;开始抢占「进行中」;结束回写成功或失败(失败带原因);重做靠人工改回可操作。状态名由本技能自定。详见 `references/WORK_ITEM_STATUS.md`。
- **同步数据**写技能本地库、不生成导出文件;**导出当前表**由宿主数据管理负责;文件型特殊报告才另做 `export-*`(落 `output/`);**HTML 整页报告**写 `html/` 并用固定 Action id `render-html`(见 `references/ACTIONS.md`)。
- **准备 vs 执行(适用则拆)**:若技能存在「先写入/准备本地数据」与「再对外产生副作用」两段,Agent 必须能分开触发;禁止默认绑成不可拆的一步。一键无准备态的技能可跳过本条。
### 如何触发(能力类型)
| 你要做什么 | Agent 怎么做 | 进任务中心? |
|------------|--------------|--------------|
| 短查询 / 只读 / 秒级(`health` / `version` / `stats` / `list` / `get` / `logs` 等) | OpenClaw bash/exec + **共享 Python** 跑 CLI(见下方「共享 Python」) | 否(CLI 当场结束) |
| 短查询 / 只读 / 秒级(`health` / `version` / `stats` / `list` / `get` 等) | OpenClaw bash/exec + **共享 Python** 跑 CLI(见下方「共享 Python」) | 否(CLI 当场结束) |
| 已在 `actions.json` 声明且 `executionProfile: "sync"` 的能力 | 可用 `run_skill_action`,或同等效果的短 CLI(同一内核) | 否 |
| 长耗时 / RPA / 浏览器 / 需要进度或取消;或 `executionProfile: "async"` | **必须** `run_skill_action`(先 `list_skill_actions` / `get_skill_action`) | **是**(仅因为该 Action 是 async) |
| 用户只问用法 / 能做什么 | 读根目录 `README.md`;逐步操作再读 `TUTORIAL.md`;**不要**为答用法去列目录或默认打开 `references/` | — |
@@ -64,11 +68,12 @@ allowed-tools:
1. **禁止**对长任务使用 `exec`/`bash` 跑 `…/scripts/main.py run`(或等价业务命令)再 `process poll` 干等。
2. **禁止**用 `uv run python …` 跑技能脚本(会建临时环境,缺 `jiangchang_skill_core`)。
3. **禁止** Agent 自行 `pip install`。
4. Action **可选**:无数据管理 / 定时 / Agent 结构化调用需求时可不提供 `actions.json`;有长任务 / RPA 时**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 Action。
4. **入口按定位表挂载**:复制业务技能后须在 `development/REQUIREMENTS.md` 填写入口定位表(见 `development/PRODUCT_SURFACES.md`)。定位需要数据管理 / 定时 / Agent 结构化调用时,必须提供 `actions.json` 并声明对应 `placements`;定位确认无需任何宿主结构化入口时可不提供。有长任务 / RPA 时**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 Action。
5. **一个业务能力一个 Action**,用多个 `placements` 挂入口;底层仍是同一 `scripts/main.py` CLI。
6. 是否进任务中心**只**看该 Action 的 `executionProfile`,与数据管理 / 定时 / Agent 入口无关。
7. **处理业务行**时遵守状态角色契约(可操作 / 进行中 / 终态);禁止对已成功/失败/跳过默认重复处理。详见 `references/WORK_ITEM_STATUS.md`。
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`(不要把 `development/` 当 Agent 主读路径)。
Agent 执行契约细节:`references/CLI.md`、`references/ACTIONS.md`、`references/SCHEMA.md`、按需 `references/POLICY.md` / `references/WORK_ITEM_STATUS.md`(不要把 `development/` 当 Agent 主读路径)。
## 共享 Python(必读)
@@ -81,7 +86,7 @@ python {baseDir}/scripts/main.py config-path
python {baseDir}/scripts/main.py init-db
```
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.2`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。
- 公共库来自共享 venv 的 `jiangchang-platform-kit>=1.2.11`(包名路径 `jiangchang_skill_core`);**不要 vendor** `scripts/jiangchang_skill_core/`。
- 根目录 `requirements.txt` **只声明技能特有**依赖;不要写 `jiangchang-platform-kit` / `playwright`。
- `metadata.openclaw.platform_kit_min_version` 是宿主兼容声明,**不是** pip 依赖。
- `health` 用 `collect_runtime_diagnostics` 做只读诊断。
@@ -126,7 +131,7 @@ python {baseDir}/scripts/main.py init-db
| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab(按版本小节 → `changelog`) |
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约(Agent 主读) |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范 |
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范;**产品入口**见 `development/PRODUCT_SURFACES.md` |
## 目录约定
@@ -141,7 +146,7 @@ python {baseDir}/scripts/main.py init-db
1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md))。
2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)。
3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制。
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。

View File

@@ -18,7 +18,8 @@
2. 已在技能市场安装本技能(或更新到最新版)
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
5. 【如需】需要的文件 / 时间范围 / 单号等已准备好
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
---
@@ -58,6 +59,7 @@
| 现象 | 你可以先试 |
|------|------------|
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |

View File

@@ -1,10 +1,11 @@
# assets
- `actions.json`:Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。
- `examples/`:CLI 成功输出形状示例(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。
- `actions.json`:Skill Action manifest 最小示例(按入口定位表决定是否保留/扩展;见 `development/PRODUCT_SURFACES.md`)。每个 action **必须**显式声明 `executionProfile`(`sync` \| `async`)。默认仅 `health` / `version` / `config-path`,不放 toolbar,故无 `bind` 示例。
- `html/example-report.html`:HTML 报告页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);渲染后须写入数据目录 `html/`,见 `references/ACTIONS.md`「HTML 页面报告」。
- `examples/`:CLI 成功输出形状示例、以及 `render-html.action.example.json`(虚构路径与数据)。
- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**)。
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping`;placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
`skill-actions.schema.json` 使用 `additionalProperties: false`。正式支持 `bind.tables`(toolbar Action 必填)与可选 `bind.inputMapping`;placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。可选 `hoverHint` 为数据管理工具栏悬停(省略不显示,不回退)。`concurrency`/`locks` 仍为预留。`placements` 与 `executionProfile` 正交——进任务中心只看 `async`,与入口无关。表级 Action 与 Agent 契约见 [`references/ACTIONS.md`](../references/ACTIONS.md);开发深度规范见 [`development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md)
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))

View File

@@ -1,4 +1,9 @@
# 示例 JSON
- `version-response.json`:`python main.py version` 成功时 stdout 单行对象的形状参考。
- `log-get-response.json`:若技能有日志表,`log-get` 成功时单条对象的形状参考。
- `skill-policy-snapshot.*.example.json`:Data Policy Snapshot 形状参考(见 `references/POLICY.md`)。
- `skill-cell-presentation.example.json`:数据管理单元格呈现文档形状参考(见 `references/CELL_PRESENTATION.md`)。
- `render-html.action.example.json`:HTML 报告「重新生成」Action 形状参考(固定 id `render-html`;见 `references/ACTIONS.md`)。
- `lookup-action.example.json`:Action 内联 Lookup 形状参考(单选 + `multiple: true` 多选并列;跨表引用勿填编号;见 `references/ACTIONS.md`「跨表引用:Lookup」)。
- `visible-when-action.example.json`:Action 参数字段条件显隐 `visibleWhen` 形状参考(互斥模式联动;见 `references/ACTIONS.md`「字段条件显隐」;≠ Policy L3)。
- `replaces-create.action.example.json` / `replaces-update.action.example.json` / `replaces-delete.action.example.json` / `replaces-import.action.example.json`:数据管理默认按钮接管(`replaces`)形状参考;**import 时 UI 仍用宿主「导入数据」面板**,确认后调技能写库(见 `references/ACTIONS.md`「`replaces`」)。

View File

@@ -1,12 +0,0 @@
{
"id": 1,
"task_type": "demo_task",
"target_id": "demo_target_1",
"input_id": "demo_input_42",
"input_title": "示例任务输入标题",
"status": "success",
"error_msg": null,
"result_summary": "{\"processed\": 10, \"skipped\": 0}",
"created_at": "2026-04-01T10:00:00",
"updated_at": "2026-04-01T10:00:00"
}

View File

@@ -0,0 +1,83 @@
{
"_comment": "形状参考:Action 参数引用同库另一张表时用 Lookup。单选与多选同一 lookup 对象,多选只加 multiple:true。完整契约见 references/ACTIONS.md「跨表引用:Lookup」与 references/SCHEMA.md。",
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "send-with-template",
"label": "按模板发送(示例)",
"description": "演示内联 lookup 单选:从 message_templates 选模板;-- 表示自动挑选。",
"placements": ["toolbar"],
"executionProfile": "async",
"interaction": "form",
"bind": {
"tables": ["leads"]
},
"entrypoint": {
"type": "cli",
"command": "engage",
"args": ["--template-id", "{{templateId}}", "--content", "{{content}}"]
},
"inputSchema": {
"type": "object",
"properties": {
"content": {
"type": "string",
"title": "正文",
"description": "填了就按这里发;留空则用下面的话术模板。",
"display_type": "textarea"
},
"templateId": {
"type": "number",
"title": "话术模板",
"description": "仅在正文留空时生效。选 -- = 自动挑选。",
"display_type": "lookup",
"lookup": {
"ref_table": "message_templates",
"value_column": "id",
"label_column": "name",
"extra_column": "channel",
"filter_column": "is_active",
"filter_value": 1
}
}
}
}
},
{
"id": "probe-with-engines",
"label": "按引擎抽检(多选示例)",
"description": "演示 lookup.multiple:从 engines 多选,提交为逗号分隔 id。",
"placements": ["toolbar"],
"executionProfile": "async",
"interaction": "form",
"bind": {
"tables": ["queries"]
},
"entrypoint": {
"type": "cli",
"command": "query",
"args": ["batch-probe", "--engine-ids", "{{engineIds}}"]
},
"inputSchema": {
"type": "object",
"properties": {
"engineIds": {
"type": "string",
"title": "将使用的引擎",
"description": "可多选;提交值为逗号分隔编号,如 1,2。",
"display_type": "lookup",
"lookup": {
"ref_table": "engines",
"value_column": "id",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
"multiple": true
}
}
}
}
}
]
}

View File

@@ -0,0 +1,28 @@
{
"id": "render-html",
"label": "生成 HTML",
"description": "根据业务数据生成 HTML 报告,写入技能数据目录 html/(示例:请改 bind.tables / entrypoint)。",
"placements": [
"toolbar",
"skill-detail",
"agent"
],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": [
"your_source_table"
]
},
"entrypoint": {
"type": "cli",
"command": "render-html",
"args": []
},
"steps": [
{
"when": "afterSuccess",
"do": "refreshCatalog"
}
]
}

View File

@@ -0,0 +1,28 @@
{
"id": "item-create",
"label": "新增记录",
"description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["items"]
},
"hostEffects": {
"onSuccess": ["reloadTable"]
},
"inputSchema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "title": "名称" },
"note": { "type": "string", "title": "备注" }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["create", "--name", "{{name}}", "--note", "{{note}}"]
}
}

View File

@@ -0,0 +1,33 @@
{
"id": "item-delete",
"label": "删除记录",
"description": "删除业务记录并清理关联副作用。",
"replaces": "delete",
"placements": [],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": {
"message": "确定删除该记录?关联数据将一并清理。"
},
"bind": {
"tables": ["items"],
"inputMapping": {
"id": "$row.$pk"
}
},
"hostEffects": {
"onSuccess": ["reloadTable", "clearSelection"]
},
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "number", "title": "编号", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["delete", "--id", "{{id}}"]
}
}

View File

@@ -0,0 +1,38 @@
{
"id": "item-import",
"label": "导入记录",
"description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。",
"replaces": "import",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["items"]
},
"hostEffects": {
"onSuccess": ["reloadTable"]
},
"inputSchema": {
"type": "object",
"required": ["filePath"],
"properties": {
"filePath": {
"type": "string",
"title": "导入文件",
"description": "由宿主导入面板传入的 Excel/CSV 路径。",
"format": "file"
},
"onError": {
"type": "string",
"title": "导入规则(出错时)",
"enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"],
"default": "跳过该行,继续导入"
}
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"]
}
}

View File

@@ -0,0 +1,34 @@
{
"id": "item-update",
"label": "编辑记录",
"description": "编辑业务记录并重算派生字段。",
"replaces": "update",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["items"],
"inputMapping": {
"id": "$row.$pk",
"name": "$row.name",
"note": "$row.note"
}
},
"hostEffects": {
"onSuccess": ["reloadTable"]
},
"inputSchema": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "number", "title": "编号", "readOnly": true },
"name": { "type": "string", "title": "名称" },
"note": { "type": "string", "title": "备注" }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"]
}
}

View File

@@ -0,0 +1,29 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"cells": [
{
"rowId": "42",
"column": "qty",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"rowId": "42",
"column": "status",
"tone": "warning",
"message": "待审核"
},
{
"rowId": "99",
"column": "print_done_at",
"tone": "danger",
"message": "环节到期未填",
"alert": true
}
]
}

View File

@@ -0,0 +1,13 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": {
"userId": "u1",
"displayName": "张三",
"roles": ["clerk"]
},
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "sync-records"]
}
}

View File

@@ -0,0 +1,28 @@
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": {
"userId": "u1"
},
"actions": {
"mode": "open"
},
"data": {
"tables": {
"orders": {
"rows": {
"mode": "scope",
"scope": {
"all": [
{
"field": "owner_user_id",
"op": "eq",
"valueFrom": "actor.userId"
}
]
}
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
{
"version": "1.0.0",
"version": "1.0.92",
"skill": "your-skill-slug"
}

View File

@@ -0,0 +1,72 @@
{
"_comment": "形状参考:互斥模式用 inputSchema.properties.*.visibleWhen 联动显隐,勿只靠 description 灰字。完整契约见 references/ACTIONS.md「字段条件显隐」。勿与 POLICY.md L3 行态 visibleWhen 混淆。",
"schemaVersion": 1,
"skill": "your-skill-slug",
"actions": [
{
"id": "scrape-links-demo",
"label": "采集链接(示例)",
"description": "演示按采集方式切换参数:关键词路径 vs 作者主页路径。",
"placements": ["toolbar"],
"executionProfile": "async",
"interaction": "form",
"bind": {
"tables": ["demo_items"]
},
"confirmation": {
"message": "将按所选方式采集链接(示例确认文案)。是否继续?"
},
"entrypoint": {
"type": "cli",
"command": "run",
"args": [
"--collect-mode",
"{{collectMode}}",
"--keyword",
"{{keyword}}",
"--author-url",
"{{authorUrl}}",
"--max-count",
"{{maxCount}}"
]
},
"inputSchema": {
"type": "object",
"required": ["collectMode"],
"properties": {
"collectMode": {
"type": "string",
"title": "采集方式",
"description": "先选方式,下方只出现对应参数。",
"enum": ["按关键词采集", "按作者主页采集"],
"default": "按关键词采集"
},
"keyword": {
"type": "string",
"title": "关键词",
"description": "例如 rpa。",
"visibleWhen": {
"field": "collectMode",
"equals": "按关键词采集"
}
},
"authorUrl": {
"type": "string",
"title": "作者主页链接",
"description": "例如 https://example.com/author/...",
"display_type": "url",
"visibleWhen": {
"collectMode": "按作者主页采集"
}
},
"maxCount": {
"type": "number",
"title": "数量上限",
"default": 50,
"minimum": 1
}
}
}
}
]
}

View File

@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{{TITLE}}</title>
<style>
:root {
--bg: #f6f8fb;
--ink: #0f172a;
--muted: #64748b;
--card: #ffffff;
--line: #e2e8f0;
}
* { box-sizing: border-box; }
html { overflow-y: auto; }
body {
margin: 0;
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg);
color: var(--ink);
}
header {
padding: 20px 24px 8px;
}
header h1 {
margin: 0 0 6px;
font-size: 22px;
font-weight: 700;
}
header p {
margin: 0;
color: var(--muted);
font-size: 13px;
}
main {
padding: 8px 24px 28px;
}
.card {
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
padding: 16px 18px;
line-height: 1.6;
font-size: 14px;
}
</style>
</head>
<body>
<header>
<h1>{{TITLE}}</h1>
<p>报告生成于 · {{GENERATED_AT}}</p>
</header>
<main>
<div class="card">
{{BODY}}
</div>
</main>
</body>
</html>

View File

@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)与可选 hoverHint(数据管理工具栏悬停,需宿主已支持该字段)。",
"type": "object",
"required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false,
@@ -19,14 +19,73 @@
"actions": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/action" }
"items": { "$ref": "#/$defs/action" },
"description": "Action 列表。同一 placement(toolbar/row/batch)下,宿主按本数组中匹配项的相对顺序展示。toolbar/batch:表顶栏全部直接展示(无「更多操作」溢出)。row:行级入口,宿主可呈现为操作列、行右键和/或行内「⋯」(同一批 Action、同一顺序);不在本字段配置排序、截断或菜单树。调整顺序即调整数组先后。无独立 sortOrder 字段。宿主内置刷新/导出/查看等不参与本顺序。表顶栏宽时左右横排,窄时左右分栏且栏内竖排。"
},
"policy": {
"$ref": "#/$defs/policyDeclaration",
"description": "可选。启用 Data Policy(L1/L2)。缺省或不 enabled = L0,宿主不拉 Snapshot。详见 references/POLICY.md。"
},
"cellPresentation": {
"$ref": "#/$defs/cellPresentationDeclaration",
"description": "可选。启用数据管理单元格呈现(P1)。缺省或不 enabled = P0,宿主不读标色。详见 references/CELL_PRESENTATION.md。"
}
},
"$defs": {
"cellPresentationDeclaration": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "仅 true 启用;false 等同 P0。"
}
}
},
"policyDeclaration": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean",
"description": "仅 true 启用;false 等同 L0。"
},
"entrypoint": {
"$ref": "#/$defs/entrypoint",
"description": "enabled=true 时必填。推荐 command=policy,args 可含 {{table}}/{{skill}}/{{userId}}。"
},
"appliesTo": {
"type": "array",
"items": {
"type": "string",
"enum": ["data-management"]
},
"description": "缺省 [\"data-management\"]。本期宿主只实现 data-management。"
}
},
"allOf": [
{
"if": {
"properties": { "enabled": { "const": true } },
"required": ["enabled"]
},
"then": {
"required": ["entrypoint"]
}
}
]
},
"placement": {
"type": "string",
"enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"],
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。"
"description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。row=行级入口(操作列/行右键/⋯ 等由宿主呈现,无独立 row-context-menu placement)。placements 与 executionProfile 正交,不做条件限制。"
},
"mutationKind": {
"type": "string",
"enum": ["create", "update", "delete", "import"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
@@ -52,9 +111,117 @@
"readOnly": {
"type": "boolean",
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "可选。绑定业务表物理列名;Action 弹窗(toolbar/batch)与 replaces create/update 抽屉均可复用该列的中文名与 lookup 下拉"
},
"display_type": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"description": "可选。表单控件提示;与 format 二选一即可。lookup=关联下拉(须配 lookup 对象或绑到已有 lookup 列)。file/directory=本地路径选择。须与 CRUD display_type 语义对齐(见 ACTIONS.md / SCHEMA.md)。"
},
"format": {
"type": "string",
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
"description": "可选。display_type 别名,语义相同。"
},
"lookup": {
"$ref": "#/$defs/lookupConfig",
"description": "可选。内联同库表关联 Lookup(与列 options_json lookup 对象同构)。用于当前表没有对应列时;推荐同时设 display_type=lookup。见 ACTIONS.md「跨表引用:Lookup」。"
},
"visibleWhen": {
"description": "可选。表单字段条件显隐。宿主在数据管理/定时任务参数表单中:规则不满足则隐藏该字段、提交时省略、且不校验 required。支持 {field,equals} / {field,in:[...]},或单键简写 {collectMode:\"按关键词采集\"} / {collectMode:[\"a\",\"b\"]}。Agent/CLI 无 UI 时同样按当前 input 求值后剥离隐藏字段。",
"oneOf": [
{
"type": "object",
"required": ["field", "equals"],
"additionalProperties": false,
"properties": {
"field": { "type": "string", "minLength": 1 },
"equals": { "type": ["string", "number", "boolean"] }
}
},
{
"type": "object",
"required": ["field", "in"],
"additionalProperties": false,
"properties": {
"field": { "type": "string", "minLength": 1 },
"in": {
"type": "array",
"minItems": 1,
"items": { "type": ["string", "number", "boolean"] }
}
}
},
{
"type": "object",
"minProperties": 1,
"maxProperties": 1,
"additionalProperties": {
"oneOf": [
{ "type": ["string", "number", "boolean"] },
{
"type": "array",
"minItems": 1,
"items": { "type": ["string", "number", "boolean"] }
}
]
}
}
]
}
}
},
"lookupConfig": {
"type": "object",
"required": ["ref_table", "value_column", "label_column"],
"additionalProperties": false,
"properties": {
"ref_table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
"description": "同库被引用表名"
},
"value_column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
"description": "提交值列(编号 / 外键)"
},
"label_column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
"description": "主文案列"
},
"extra_column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
"description": "可选。第三展示列;有值时宿主拼成「value · label(extra)」"
},
"filter_column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$",
"description": "可选。等值过滤列"
},
"filter_value": {
"type": ["string", "number", "boolean", "null"],
"description": "可选。与 filter_column 配对的等值"
},
"multiple": {
"type": "boolean",
"description": "可选。true=多选 Lookup(存/提交 TEXT 逗号分隔 id);省略或 false=单选(默认)。与 CRUD options_json lookup 同构。"
}
},
"description": "同库 FK 式 Lookup。展示:value · label;有 extra 时 value · label(extra)。multiple 时多选。宿主亦接受 camelCase 键名。"
},
"inputSchema": {
"type": "object",
"required": ["type", "properties"],
@@ -120,9 +287,87 @@
"type": "object",
"additionalProperties": { "type": "string", "minLength": 1 },
"description": "可选。将 $row.$pk / $row.col / $selection.ids 等映射到 inputSchema 字段名,供 row/batch 预填。"
},
"columnMapping": {
"type": "object",
"additionalProperties": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$"
},
"description": "可选。将 inputSchema 属性名映射到业务表物理列名,供 Action 弹窗与 replaces create/update 抽屉复用列 displayName / lookup options。"
}
},
"description": "表级绑定;toolbar 必填。可选 inputMapping 供行内/批量预填。concurrency/locks 勿写入。"
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。"
},
"hostEffectId": {
"type": "string",
"enum": ["reloadTable", "refreshCatalog", "clearSelection"],
"description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。"
},
"hostEffects": {
"type": "object",
"additionalProperties": false,
"properties": {
"onSuccess": {
"type": "array",
"items": { "$ref": "#/$defs/hostEffectId" },
"uniqueItems": true,
"description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。"
},
"onFailure": {
"type": "array",
"items": { "$ref": "#/$defs/hostEffectId" },
"uniqueItems": true,
"description": "sync 失败,或 async 启动被拒时触发。"
}
},
"description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。"
},
"actionStep": {
"type": "object",
"required": ["when", "do"],
"additionalProperties": false,
"properties": {
"when": {
"type": "string",
"enum": ["before", "afterSuccess", "afterFailure"],
"description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。"
},
"do": {
"type": "string",
"enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"],
"description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。"
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "deleteByFk / deleteRows 目标表(snake_case)。"
},
"fk": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "deleteByFk 时子表外键列名。"
}
},
"allOf": [
{
"if": {
"properties": { "do": { "const": "deleteByFk" } },
"required": ["do"]
},
"then": { "required": ["table", "fk"] }
},
{
"if": {
"properties": { "do": { "const": "deleteRows" } },
"required": ["do"]
},
"then": { "required": ["table"] }
}
]
},
"action": {
"type": "object",
@@ -142,12 +387,18 @@
"type": "string",
"minLength": 1
},
"hoverHint": {
"type": "string",
"minLength": 1,
"description": "可选。数据管理工具栏悬停文案。与 description / confirmation.message 独立;省略则不显示悬停,不回退。需宿主已支持该字段。当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出。"
},
"placements": {
"type": "array",
"minItems": 1,
"minItems": 0,
"items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交"
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
},
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" },
"inputSchema": { "$ref": "#/$defs/inputSchema" },
"confirmation": { "$ref": "#/$defs/confirmation" },
@@ -156,7 +407,47 @@
"enum": ["sync", "async"],
"description": "sync=调用方等待结构化结果(不建 Job);async=宿主建后台 Job 并进任务中心。必须显式写出;长耗时建议 async,但不限制 placements。"
},
"bind": { "$ref": "#/$defs/bind" }
"interaction": {
"type": "string",
"enum": ["auto", "direct", "confirm", "form"],
"description": "数据管理等可点击入口的交互方式(与 executionProfile 正交)。direct=一点即跑;confirm=轻量确认(文案用 confirmation.message);form=参数表单;auto/省略=宿主按 inputSchema+confirmation 推断。cron 触发与 agent 忽略本字段。"
},
"interactionByPlacement": {
"type": "object",
"additionalProperties": false,
"properties": {
"toolbar": { "type": "string", "enum": ["direct", "confirm", "form"] },
"row": { "type": "string", "enum": ["direct", "confirm", "form"] },
"batch": { "type": "string", "enum": ["direct", "confirm", "form"] },
"cron": { "type": "string", "enum": ["direct", "confirm", "form"] },
"agent": { "type": "string", "enum": ["direct", "confirm", "form"] },
"skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] },
"create": { "type": "string", "enum": ["direct", "confirm", "form"] },
"update": { "type": "string", "enum": ["direct", "confirm", "form"] },
"delete": { "type": "string", "enum": ["direct", "confirm", "form"] },
"import": { "type": "string", "enum": ["direct", "confirm", "form"] }
},
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
},
"bind": { "$ref": "#/$defs/bind" },
"hostEffects": { "$ref": "#/$defs/hostEffects" },
"steps": {
"type": "array",
"maxItems": 8,
"items": { "$ref": "#/$defs/actionStep" },
"description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。"
},
"authz": {
"type": "object",
"additionalProperties": false,
"properties": {
"capability": {
"type": "string",
"minLength": 1,
"description": "可选文档/静态提示用能力名;宿主不得仅凭此显隐按钮,运行时以 policy Snapshot 为准。"
}
}
}
},
"if": {
"properties": {
@@ -169,7 +460,17 @@
"then": {
"required": ["bind"]
},
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 时必须声明 bind.tables。"
"allOf": [
{
"if": {
"required": ["replaces"]
},
"then": {
"required": ["bind"]
}
}
],
"description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。"
}
}
}

View File

@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
"title": "SkillCellPresentationDocument",
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 references/CELL_PRESENTATION.md。",
"type": "object",
"required": ["schemaVersion", "skill", "table", "rowKey", "cells"],
"additionalProperties": false,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1,
"description": "与 SKILL.md metadata.openclaw.slug / actions.json skill 一致"
},
"table": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "业务表物理名(snake_case)"
},
"rowKey": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "用于匹配行的业务表列名(如 id、feishu_record_id);cells[].rowId 的值取自该列"
},
"generatedAt": {
"type": "string",
"minLength": 1,
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
},
"cells": {
"type": "array",
"description": "当前应对该表生效的呈现项;空数组 = 该表无特殊呈现(清除旧样式)。同 (rowId, column) 重复时宿主可后者覆盖前者。",
"items": { "$ref": "#/$defs/cellItem" }
}
},
"$defs": {
"tone": {
"type": "string",
"enum": ["danger", "warning", "info", "success", "muted"],
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
},
"cellItem": {
"type": "object",
"required": ["rowId", "column", "tone"],
"additionalProperties": false,
"properties": {
"rowId": {
"type": "string",
"minLength": 1,
"description": "rowKey 列的字符串形式主键/业务键"
},
"column": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$",
"description": "要强调的业务列名(snake_case),必须是真实列"
},
"tone": { "$ref": "#/$defs/tone" },
"message": {
"type": "string",
"maxLength": 500,
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
},
"alert": {
"type": "boolean",
"description": "可选,默认 false。true = 可计入进页/同步后的提醒汇总(如 Toast);填完数据并重算后该项消失则提醒自然关闭"
}
}
}
}
}

View File

@@ -0,0 +1,196 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-policy-snapshot.schema.json",
"title": "SkillDataPolicySnapshot",
"description": "匠厂 Skill Data Policy Snapshot(schemaVersion 1)。由技能 policy CLI 输出到 stdout;宿主用于数据管理按钮/行列过滤。详见 references/POLICY.md。",
"type": "object",
"required": ["schemaVersion", "skill", "actor", "actions"],
"additionalProperties": true,
"properties": {
"schemaVersion": {
"type": "integer",
"const": 1
},
"skill": {
"type": "string",
"pattern": "^[a-z0-9]+(-[a-z0-9]+)*$",
"minLength": 1
},
"actor": {
"type": "object",
"required": ["userId"],
"additionalProperties": true,
"properties": {
"userId": { "type": "string", "minLength": 1 },
"displayName": { "type": "string" },
"roles": {
"type": "array",
"items": { "type": "string", "minLength": 1 }
},
"attributes": {
"type": "object",
"additionalProperties": {
"type": ["string", "number", "boolean", "null"]
}
}
}
},
"actions": {
"type": "object",
"required": ["mode"],
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["allowlist", "open"],
"description": "v1 仅允许 allowlist/open;denylist 非法。"
},
"ids": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
},
"description": "mode=allowlist 时必填;mode=open 时应省略或 []。"
}
},
"allOf": [
{
"if": {
"properties": { "mode": { "const": "allowlist" } },
"required": ["mode"]
},
"then": {
"required": ["ids"]
}
}
]
},
"hostCapabilities": {
"type": "array",
"items": {
"type": "string",
"enum": ["refresh", "export", "view", "edit", "delete", "import"]
},
"description": "省略=不限制宿主内置能力;写出则按 allowlist(含 []=全禁)。"
},
"data": {
"type": "object",
"additionalProperties": false,
"properties": {
"tables": {
"type": "object",
"additionalProperties": { "$ref": "#/$defs/tablePolicy" }
}
}
},
"extensions": {
"type": "object",
"description": "技能私有扩展;宿主必须忽略未知键。"
}
},
"$defs": {
"predicate": {
"type": "object",
"required": ["field", "op"],
"additionalProperties": false,
"properties": {
"field": {
"type": "string",
"minLength": 1,
"pattern": "^[a-z][a-z0-9_]*$"
},
"op": {
"type": "string",
"enum": [
"eq",
"ne",
"in",
"not_in",
"gt",
"gte",
"lt",
"lte",
"is_null",
"is_not_null"
]
},
"value": {
"description": "与 valueFrom 互斥;in/not_in 时为非空数组。"
},
"valueFrom": {
"type": "string",
"const": "actor.userId",
"description": "v1 仅允许 actor.userId。"
}
}
},
"scope": {
"oneOf": [
{ "$ref": "#/$defs/predicate" },
{
"type": "object",
"required": ["all"],
"additionalProperties": false,
"properties": {
"all": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/predicate" }
}
}
},
{
"type": "object",
"required": ["any"],
"additionalProperties": false,
"properties": {
"any": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/predicate" }
}
}
}
]
},
"tablePolicy": {
"type": "object",
"additionalProperties": false,
"properties": {
"accessible": { "type": "boolean" },
"columns": {
"type": "object",
"additionalProperties": false,
"properties": {
"visible": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
}
},
"writable": {
"type": "array",
"items": {
"type": "string",
"pattern": "^[a-z][a-z0-9_]*$"
}
}
}
},
"rows": {
"type": "object",
"additionalProperties": false,
"properties": {
"mode": {
"type": "string",
"enum": ["scope", "open", "delegate"]
},
"scope": { "$ref": "#/$defs/scope" }
}
}
}
}
}
}

View File

@@ -1,21 +0,0 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/task-log-record.schema.json",
"title": "TaskLogRecord",
"description": "通用业务技能 log-get 返回的单条任务日志对象模板",
"type": "object",
"required": ["id", "task_type", "status", "created_at", "updated_at"],
"properties": {
"id": { "type": "integer" },
"task_type": { "type": "string" },
"target_id": { "type": ["string", "null"] },
"input_id": { "type": ["string", "null"] },
"input_title": { "type": ["string", "null"] },
"status": { "type": "string" },
"error_msg": { "type": ["string", "null"] },
"result_summary": { "type": ["string", "null"] },
"created_at": { "type": ["string", "null"] },
"updated_at": { "type": ["string", "null"] }
},
"additionalProperties": true
}

View File

@@ -0,0 +1,216 @@
# account-manager 接入标准(账号 / Profile / 租约)
> 本文是浏览器 RPA 技能接入账号体系的统一标准。开发者和 AI 编程工具在编写 `account_client.py`、处理多账号、排查登录状态或验收浏览器任务前,必须阅读本文。
## 目录
- [1. 为什么必须依赖 account-manager](#1-为什么必须依赖-account-manager)
- [2. 核心模型](#2-核心模型)
- [3. 强制规则](#3-强制规则)
- [4. 标准生命周期](#4-标准生命周期)
- [5. 账号选择与多账号](#5-账号选择与多账号)
- [6. 创建后去哪里查看](#6-创建后去哪里查看)
- [7. 登录状态与 Profile 验收](#7-登录状态与-profile-验收)
- [8. 租约与异常退出](#8-租约与异常退出)
- [9. 工业级测试](#9-工业级测试)
- [10. 排障顺序与提问证据](#10-排障顺序与提问证据)
- [11. 安全边界](#11-安全边界)
## 1. 为什么必须依赖 account-manager
真实用户会长期使用同一个账号,也会在批量发布、矩阵运营、多店铺、多租户等场景中使用多个账号。一次打开临时浏览器、成功操作一两条数据,不能证明技能可交付。
account-manager 统一负责:
- 账号身份及平台归属;
- 一账号一份持久化浏览器 Profile;
- Cookie、会话和登录状态的持续复用;
- 多任务之间的账号租约与并发互斥;
- 账号状态、最近错误和运维排查入口。
业务技能只负责编排业务动作,不得再造一套账号库,也不得随意找一个浏览器目录直接运行。
## 2. 核心模型
| 对象 | 含义 | 业务技能应如何使用 |
|------|------|--------------------|
| `account` | 某个平台上的一个账号身份 | 通过 account-manager CLI 获取,不自行落库 |
| `profile_dir` | 该账号独占的浏览器用户数据目录 | 只传给 `launch_persistent_context`,长期复用 |
| 登录状态 | 目标站 Cookie / Session 等实际会话 | 由业务技能打开同一 Profile 后检查;账号已登记不代表已登录 |
| `lease_token` | 当前任务对账号的临时独占权 | 打开浏览器前取得,所有退出路径释放 |
| `session_status` / error | 会话健康和最近故障线索 | 登录失效或账号异常时按约定回写 |
关系必须保持为:
```text
一个账号记录 ──绑定──► 一个稳定 profile_dir
│
└──任务运行前取得 lease_token ──► 同一时刻只允许一个任务使用
```
## 3. 强制规则
1. 所有浏览器 RPA,包括公开页和无需登录的页面,都必须先经 account-manager 取得 `profile_dir`。
2. 一个账号只能绑定一个稳定 Profile;不同账号禁止共用 Profile。
3. 禁止使用本机日常 Chrome / Edge 用户目录,禁止每次运行创建无归属的临时 Profile。
4. 获取账号时必须同时取得租约;仅 `account get <id>` 不能代替租约。
5. 业务代码只通过单一 `scripts/service/account_client.py` 集中调用 account-manager CLI。
6. 禁止 import account-manager 的 `service/`、`db/`、`util/`、`rpa_helpers` 等内部模块。
7. 未取得非空 `profile_dir` 和 `lease_token` 时禁止打开浏览器。
8. 账号均被占用时返回 `ERROR:LEASE_CONFLICT`,不得自动创建重复账号或换号绕过。
9. 所有成功、失败、取消和异常路径都必须在 `finally` 中释放租约。
10. 密码、Cookie、Token、短信验证码等敏感信息不得写入业务技能配置、数据库、日志或错误信息。
对应机器门禁:`POLICY-RPA-004`、`POLICY-RPA-006`。
## 4. 标准生命周期
```text
解析用户指定账号
→ ensure / pick 账号并取得租约
→ 校验账号平台、profile_dir、lease_token
→ 用 profile_dir 启动持久浏览器
→ 打开目标站并检查实际登录状态
→ 执行业务并记录结果
→ 回写会话状态 / 最近错误
→ 关闭浏览器
→ finally 释放租约
```
参考结构:
```python
account = pick_web_account(platform=TARGET_PLATFORM, account_id=account_id, login_id=login_id)
lease_token = account["lease_token"]
try:
profile_dir = account["profile_dir"]
# 只用 profile_dir 启动 persistent context,然后检查登录并执行任务
finally:
release_lease(lease_token)
```
浏览器启动、登录门禁、业务执行和关闭顺序见 [`RPA.md`](RPA.md) §0.2;CLI 封装边界见 [`ADAPTER.md`](ADAPTER.md)。
## 5. 账号选择与多账号
选择优先级:
1. 用户明确指定纯数字 `DEFAULT_ACCOUNT_ID`:先确认账号存在且平台一致,再通过可定位该账号的 pick 流程取得租约;不得只 `get` 后返回空租约。
2. 用户指定 `DEFAULT_LOGIN_ID`:使用 `ensure-web --login-id` 获取或登记,并取得租约。
3. 用户未指定账号:按平台 `ensure-web`,有则选择可用账号,无则登记一条。
4. 多条记录无法唯一确定:返回 `ERROR:AMBIGUOUS_ACCOUNT`,要求用户明确选择。
5. 匹配账号均被占用:返回 `ERROR:LEASE_CONFLICT`,等待或由运维确认租约状态。
批量和矩阵任务必须以账号为隔离单元:
- 每个账号独立 Profile、独立租约、独立任务结果和失败原因;
- 同一账号默认串行,不因追求吞吐提高同账号并发;
- 一个账号失败不得污染其他账号的 Profile 或结果;
- 设置单账号批量上限、单位时间操作上限和任务总时长;
- 风控、封禁或权限错误不得自动切换下一个账号继续试探。
## 6. 创建后去哪里查看
账号创建成功后必须可见、可查,不得只相信命令没有报错。
### 6.1 宿主数据管理
优先在匠厂宿主的**数据管理页面**查看 account-manager 数据:
- 账号记录:确认账号 ID、平台、账号名称 / 登录标识、状态、`profile_dir`、会话状态;
- 租约记录:确认当前持有者、用途、到期时间和是否已释放;
- 凭据记录:只能查看类型、引用和脱敏值,不应出现明文密码或 Token。
宿主版本若直接展示数据库表,对应核心表为 `accounts`、`account_leases`、`credentials`、`platforms`。不得在数据管理页面或 SQLite 工具中随意修改记录。
### 6.2 account-manager CLI
在 account-manager 仓库或宿主提供的入口中检查:
```powershell
python scripts/main.py account list --platform <platform_key> --limit 50
python scripts/main.py account get <account_id>
python scripts/main.py lease list --active
python scripts/main.py account open <account_id>
```
`account open` 仅用于查看指定 Profile。业务运行仍须走带租约的 pick / ensure 流程。
### 6.3 本地用户数据目录
实际数据按宿主注入的用户隔离:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/account-manager/
├── account-manager.db
└── profiles/
└── {domain}/
└── {platform_key}/
└── {safe_label}[_{login_id_suffix}]/
```
- `account-manager.db` 保存账号、平台、凭据元数据和租约。
- `profiles/...` 是真实浏览器用户数据目录,保存对应账号的 Cookie / Session 等浏览器状态。
- 实际路径以 `account get <id>` 返回的 `profile_dir` 为准,不得在代码里拼写固定盘符或用户 ID。
- Profile 正在使用时禁止复制、移动、删除或同时打开;需要迁移或清理时先停止任务并释放租约。
## 7. 登录状态与 Profile 验收
“账号已创建”只表示账号记录和 Profile 已准备,不代表目标站已经登录。
最小验收必须完成:
1. 数据管理页面或 `account list` 能看到账号记录。
2. `profile_dir` 非空、真实存在,并位于当前用户的 account-manager 数据目录。
3. 两个不同账号的 `profile_dir` 不相同。
4. 第一次用该 Profile 打开目标站,按平台要求人工登录或扫码。
5. 正常关闭浏览器并释放租约。
6. 再次取得同一账号租约,用同一 `profile_dir` 重开浏览器。
7. 验证登录状态仍有效;失效时返回 `ERROR:REQUIRE_LOGIN`,不得假成功。
## 8. 租约与异常退出
- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。
- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。
- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。
- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。
## 9. 工业级测试
除单账号冒烟测试外,至少覆盖:
- 指定账号 ID、指定登录标识和自动选择三条路径;
- 账号不存在、平台不匹配、Profile 缺失、返回格式异常;
- 同一账号并发抢占和 `LEASE_CONFLICT`;
- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放;
- 登录过期、验证码 / 二次验证、账号风险和权限不足;
- 两个以上账号轮换且 Profile、登录态、结果互不污染;
- 关闭重开后登录保持;
- 批量长时间运行、限流、有限重试和断点续跑。
不得以“单账号成功操作一两次”作为多账号或矩阵能力已经交付的证明。
## 10. 排障顺序与提问证据
开发者和 AI 在群里提问前按顺序自查:
1. 阅读本文以及 [`RPA.md`](RPA.md)、[`ADAPTER.md`](ADAPTER.md)。
2. 在数据管理页面确认账号记录是否存在、平台和状态是否正确。
3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。
4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。
5. 执行 `lease list --active`,确认是否被其他任务占用或残留。
6. 查看运行日志、任务中心终态、错误码、失败截图和浏览器当前页面。
7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。
仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。
## 11. 安全边界
- 账号和 Profile 只能用于用户或企业明确授权的正常业务操作。
- 多账号管理用于账号隔离、矩阵业务和稳定调度,不得用于绕过频控、封禁或平台限制。
- 禁止破解协议、逆向客户端、伪造设备指纹、验证码破解、养号、撞库、代理池轮换或自动换号继续试探。
- 风控、封禁、安全警告和权限不足必须停止或转人工。
- 不手工编辑 `account-manager.db`,不在账号使用期间操作 Profile 文件。
完整合规与风控边界见 [`AI_RPA.md`](AI_RPA.md)。

View File

@@ -102,9 +102,10 @@ def get_adapter():
2. **普通兄弟技能调用**,优先走统一 **`service.sibling_bridge`**(`call_sibling_json`),**不要**在 `task_service.py`、`task_rpa.py` 等业务流程文件中到处散落 `subprocess.run`。
3. **account-manager 账号/租约能力**是例外:可参考 `examples/real_browser_rpa/scripts/service/account_client.py` 与 `examples/simulator_browser_rpa/scripts/service/account_client.py`,封装为**单一** `account_client.py`;允许在该文件内部集中通过 subprocess 调 account-manager CLI。**禁止** import `rpa_helpers` 等 account-manager 内部模块;`simulator_rpa` 与 `real_rpa` 均走同一 `pick_web_account` / `release_lease` 模式。
4. **不允许**直接 `import account-manager` 的内部 Python 模块(如 `service/`、`util/`、`db/`)。
5. **pick/ensure lease 后必须 `finally release lease`**;进程被 kill 后可能残留 lease,需在运维文档说明排查方式(查 account-manager lease 列表 / 手动释放)。
5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。
6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → `get <id>`;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id`;皆空 → 按平台 `ensure-web`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配 → `ERROR:AMBIGUOUS_ACCOUNT`。
7. **账号选择优先级**:纯数字 `DEFAULT_ACCOUNT_ID` → 校验指定账号后带租约 pick;`DEFAULT_LOGIN_ID` / 非数字标识 → `ensure-web --login-id --lease`;皆空 → 按平台 `ensure-web --lease`。人读展示优先 `login_id` / `account_label`,不要只用数字 id。多条匹配或无法确保拿到指定 ID → `ERROR:AMBIGUOUS_ACCOUNT`。
8. **运行前校验**:`profile_dir` 和 `lease_token` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。
```python
# 普通兄弟技能 — 走 sibling_bridge
@@ -127,5 +128,6 @@ finally:
## 相关文档
- `RPA.md` — 三端 RPA 技术选型与拟人/反反爬范式
- `ACCOUNT_MANAGER.md` — 账号、Profile、租约、多账号、查看与排障标准
- `CONFIG.md` — `.env` 里如何配置运行模式与目标地址
- `TESTING.md` — 测试 target 与隔离体系

View File

@@ -81,7 +81,7 @@ Agent 与编排层必须确认:
- 先验证业务成功标志,再记录成功;不得把“点击完成”当成“业务成功”。
- 返回成功、部分成功、失败、停止或等待人工等明确状态。
- 保存必要的任务日志、错误码、失败截图和可选录屏。
- 保存必要的运行日志、错误码、失败截图和可选录屏。
- Agent 根据结构化结果解释完成数量、失败原因和下一步,不直接粘贴技术日志。
## 4. 合规与技术红线
@@ -141,6 +141,13 @@ Agent 与编排层必须确认:
页面加载、控件可用、弹窗消失、保存成功、下载完成等必须等待**真实条件**并设置超时。固定等待不能代替状态判断。
自 **platform-kit 1.2.11** / 本模板 **1.0.78** 起:
- 统一使用 `jiangchang_skill_core.rpa.poll_until`(或 `poll_until_sync`),内部已接 `interruptible_sleep`。
- 先有界等待就绪,再区分「加载慢」与「页不对」;超时优先 `ERROR:UI_NOT_READY`。
- 不要把慢机器症状映射成配置错误、网络错误或频控,除非有明确证据。
- 详细表与纪律见 [`RPA.md`](RPA.md) §0.3 / §4。
### 操作节奏
真实业务环境中,两个正常业务动作之间默认随机等待 **1~3 秒**:

View File

@@ -52,11 +52,14 @@ checklist_version: 1.0
- [ ] S0-1 交互形态:纯本地 / API / 网页RPA / 桌面RPA / 手机RPA / 编排(调兄弟技能)
- [ ] S0-2 目标站登录策略:required / optional / not_needed / 不适用
- [ ] S0-3 是否依赖账号会话(account-manager 或等价 Profile):是 / 否
- [ ] S0-4 持久化:无 / 仅任务日志 / 另有业务表
- [ ] S0-4 持久化:无 / 另有业务表
- [ ] S0-5 Skill Action:无 / 仅同步短任务 / 含异步长任务
- [ ] S0-6 是否需要在任务中心查看进度或结果:是 / 否
- [ ] S0-7 外部系统账号、数据、设备与操作权限的授权来源:
- [ ] S0-8 是否包含提交、付款、删除、审批、发布、发送等不可逆副作用:是 / 否
- [ ] S0-9 **入口定位表**(见 `PRODUCT_SURFACES.md` / `REQUIREMENTS` §0.1):已填写 / 未填写
- [ ] S0-10 本技能宣称的主用户路径涉及:对话 / 数据管理(执行或仅展示) / 定时(可多选;与定位表一致)
- [ ] S0-11 **业务行生命周期**(见 `references/WORK_ITEM_STATUS.md` / `REQUIREMENTS` §0.3):有处理队列且已声明状态→角色映射 / 不适用
---
@@ -86,6 +89,8 @@ checklist_version: 1.0
- [ ] A3-2 **P0** 缺参或非法输入时稳定失败(可读错误,不假成功)
- [ ] A3-3 **P1** 失败可诊断:能从任务记录/提示判断失败阶段,而非只有“失败了”
- [ ] A3-4 **P0** 同一能力从已声明的不同入口触发时,业务结果一致(不出现入口分叉成两套逻辑)
- [ ] A3-5 **P0** `REQUIREMENTS` 入口定位表已填写;定位为「挂」的入口在 `actions.json`(及必要表元数据)中有对应声明
- [ ] A3-6 **P1** 定位为「不挂」的入口有书面理由(不是空白遗漏)
---
@@ -96,6 +101,8 @@ checklist_version: 1.0
- [ ] A4-3 **P0** 异步任务在任务中心能看到进行中与结束状态;结束有成功或失败结论
- [ ] A4-4 **P1** 进行中的进度/步骤信息对用户可理解
- [ ] A4-5 **P1** 已对外暴露的动作参数覆盖主路径关键输入(无“命令行能做、动作入口不能做”的关键缺口)
- [ ] A4-6 **P0** 若 Action 需引用同库另一张业务表(话术/平台/账号等):参数为 Lookup 下拉(内联 `lookup` 或绑列),**不是**手填内部编号;可选「自动」可留空提交
- [ ] A4-7 **P1** 若 Action 有互斥采集/操作模式(如「按关键词 / 按主页」):用 `inputSchema` 字段级 **`visibleWhen`** 联动显隐,勿只靠 `description` 灰字说明「仅某某时填写」(见 `references/ACTIONS.md`;勿与 Policy L3 行态 `visibleWhen` 混淆)
---
@@ -107,6 +114,12 @@ checklist_version: 1.0
- [ ] A5-4 **P1** 浏览器窗口启动形态满足交付约定(如最大化或可视区域足够操作与人工介入)
- [ ] A5-5 **P1** 验证码或人工确认:有等待与超时;超时后任务失败可理解,不无限挂死或假成功
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放
- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录
- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染
- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理
---
@@ -117,15 +130,22 @@ checklist_version: 1.0
- [ ] A6-3 **P1** 重复执行同一业务键时,结果符合宣称(更新/去重/追加),无莫名脏数据或静默丢数据
- [ ] A6-4 **P1** 「零结果成功」与「失败」可区分
- [ ] A6-5 **P1** 若产出文件:落在约定数据位置且可找到,不丢在不明临时路径
- [ ] A6-6 **P0** 有业务表时:`sqlite_master` 中业务表 DDL **不得**含 SQLite `FOREIGN KEY` / `REFERENCES`;跨表只存业务 id,关联与级联在 repository/service;界面名称用 `lookup`(见 `references/SCHEMA.md`);可跑 `validate_template_database` / `init-db` 后自检
- [ ] A6-7 **P0** 有「处理业务行」类 Action 时(见 `WORK_ITEM_STATUS.md`):默认只处理**可操作**角色状态;成功/失败/跳过/进行中再点处理应拒绝或跳过且不产生重复副作用;重做须人工改回可操作。无处理队列 → **N/A**
- [ ] A6-8 **P0** 同上适用时:开始处理后业务表进入**进行中**;成功回写成功类终态;失败回写失败类终态且**失败原因**可读(任务成功但业务行仍停在可操作/进行中 → **FAIL**)
- [ ] A6-9 **P1** 同上适用时:`REQUIREMENTS` §0.3(或等价)已填写本技能状态→角色映射;处理类 Action 确认文案写清仅可操作态
---
## 7. 可观测与安全观感(所有技能)
- [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息
- [ ] A7-2 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束)
- [ ] A7-3 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL**
- [ ] A7-4 **P2** 用户可见文案偏业务语言,而非大段开发黑话
- [ ] A7-2 **P0** 统一文件日志(运行日志 / `jiangchang.log`)能定位失败阶段:可见 `task_start` 与 `task_failed|task_success`;失败含 `error_code=`;**不只** `cli_start`
- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态
- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`)
- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL**
- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话
- [ ] A7-7 **P2** 数据管理技能按钮(toolbar / row / batch)中文名对照 `references/ACTIONS.md`「Action 按钮中文名」;不与宿主「查看 / 编辑 / 删除」抢名;打开类成功不弹成功条。行业专有按钮名可自创
---
@@ -141,6 +161,12 @@ checklist_version: 1.0
- [ ] A8-8 **P1** 只对明确可重试错误做有限重试与递增退避;风险、封禁、权限和结果不明确时不盲目重试
- [ ] A8-9 **P1** 流程结束先核验业务结果并完成日志 / 截图 / 可选视频,再保留合理结束缓冲后关闭会话
- [ ] A8-10 **P1** Agent 能区分成功、部分成功、失败、等待人工、等待确认和主动停止,并用业务语言解释结果
- [ ] A8-11 **P0** 关键元素均有真实拾取证据和 element map;没有真实 DOM/UIA/XML 时未由 AI 猜 selector
- [ ] A8-12 **P0** 不可逆动作定位器在目标作用域内唯一;没有用 `.first` / `hwnds[0]` / `instance=0` 掩盖歧义
- [ ] A8-13 **P1** 页面、窗口、弹窗、iframe、Native/WebView 等作用域明确;列表先定位业务行再定位动作元素
- [ ] A8-14 **P1** 重启浏览器/软件/App 后关键定位复测通过;动态列表顺序变化不会误点
- [ ] A8-15 **P1** 定位失败保存截图和 DOM/UIA/XML、候选数量、作用域及目标版本;结构变化时停止而非在线猜新定位器
- [ ] A8-16 **P1** 所有 fallback 均经过人工验证;图像/OCR/坐标仅在控件树确认不可用后使用
---
@@ -148,7 +174,7 @@ checklist_version: 1.0
- [ ] A9-1 **P0** 仓库默认测试套件通过(执行技能约定的一键测试命令,如 `python tests/run_tests.py -v`)
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
- [ ] A9-3 **P0** **NEED_MANUAL** 已声明的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
- [ ] A9-4 **P1** 对外版本一致:详情、version、更新日志、发布标签能对上
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)

View File

@@ -104,25 +104,28 @@ STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
# 浏览器启动超时(可选):限制 launch 最长时间;注释掉或不设=不限制
# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器时可开启
```
四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_DOWNLOAD_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
### 数据目录子路径(下载 / 导入 / 导出)
### 数据目录子路径(输入 / 输出)
技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/`、`imports/`、`exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md)
- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法
- 可选 env:`SKILL_DOWNLOAD_DIR`、`SKILL_IMPORT_DIR`、`SKILL_EXPORT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
- 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
### 录屏开关(`OPENCLAW_RECORD_VIDEO`)
- **模板默认 `OPENCLAW_RECORD_VIDEO=0`**,降低本地开发 / 调试成本(不启 ffmpeg、不生成 MP4)。
- 需要**生产留证、合规记录、客户现场复现或排障**时,可在用户 `.env` 或进程环境变量中设为 `OPENCLAW_RECORD_VIDEO=1`。
- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `result_summary`(见 `RPA.md` §5.3)。
- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `finish()`(见 `RPA.md` §5.3)。
- **已落盘的用户 `.env` 不会被模板整体覆盖**;老用户保留原值,技能升级时仅通过 `merge_missing_env_keys` **追加** `.env.example` 中新增的 key。
## 🚫 红线:敏感信息不进 `.env`
@@ -167,11 +170,13 @@ config.get_float("STEP_DELAY_MIN", 1.0)
### 空值、行尾注释与假默认(硬规则)
- **留空即未配置**:`KEY=` 或 `KEY= # 说明` 必须读成空字符串,再走技能自己的「自动 / ensure」逻辑;**禁止**把示例说明、`# 可填…` 当成真实配置值。
- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明 `platform_kit_min_version: "1.2.2"`(或更高)。
- **需要 platform-kit ≥ 1.2.2**:旧版解析曾把「空值 + 行尾 `#`」误当成 `# 可填…` 这种假值。技能若依赖正确空值语义,须在 `SKILL.md` 声明不低于该版本。
- **需要 platform-kit ≥ 1.2.11(本模板默认)**:RPA 有界就绪等待 `poll_until`、可选 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`、health 中只读 `machine_power`。新技能直接声明 `"1.2.11"`。
- **网页账号配置语义**(有则用):
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;非数字非空可当 login_id(不推荐,优先用 `DEFAULT_LOGIN_ID`)
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`
- `DEFAULT_ACCOUNT_ID`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get`
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐
- 占位串(`#…`、`none`、`n/a`、`default-account`)一律当未配置
- **用户隔离必须一致**:调用 account-manager 时继承宿主注入的 `JIANGCHANG_DATA_ROOT` / `JIANGCHANG_USER_ID`;不得覆盖为开发者自己的固定路径,否则账号会写到另一位用户的数据目录。
- **不要**在 `.env.example` 里塞「看起来像真值」的演示账号(如假手机号),以免首次落盘后被当成用户配置。
## health / config-path
@@ -189,6 +194,7 @@ config.get_float("STEP_DELAY_MIN", 1.0)
## 相关文档
- `RPA.md` — 三端 RPA 标准与各开关含义(开发向;勿写入 `.env` 注释)
- `ACCOUNT_MANAGER.md` — 账号选择、Profile、租约、数据位置与验收
- `ADAPTER.md` — `OPENCLAW_TEST_TARGET` 四档模式(开发向;勿写入 `.env` 注释)
- `RUNTIME.md` — `JIANGCHANG_DATA_ROOT` 与数据根约定
- `DATA_PATHS.md` — 下载/导入/导出等子目录与 `resolve_data_path` 规范

View File

@@ -1,6 +1,6 @@
# 用户数据目录与文件路径黄金标准
> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
> 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
---
@@ -30,11 +30,23 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
## 2. 标准子目录树
用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个):
| 子目录 | 方向 | 用途 | 默认 helper |
|--------|------|------|-------------|
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器):
| 子目录 | 用途 | 默认 helper |
|--------|------|-------------|
| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` |
内部 / 可选目录(**不上侧栏**):
| 子目录 | 用途 | 默认 helper |
|--------|------|-------------|
| `downloads/` | 从外部拉取的原始文件(视频、回单、PDF…) | `get_downloads_dir()` |
| `imports/` | 用户/Agent 放入的待处理清单(urls.txt、batch.json、CSV) | `get_imports_dir()` |
| `exports/` | 导出给用户的结果(xlsx/csv/json) | `get_exports_dir()` |
| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` |
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
@@ -45,6 +57,20 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
复制后若业务不需要某目录,可不创建;**但一旦写入该类文件,必须走对应 helper**。
**开发者硬规则:**
- 用户/Agent **放进来**的文件 → `input/`(`get_input_dir()`)
- 技能/宿主 **交给用户拿走**的文件 → `output/`(`get_output_dir()`)
- 可浏览的 **HTML 报告页** → `html/`(`get_html_dir()` / `service.html_page_service`);**不要**把整页报告塞进 `output/` 指望侧栏预览
- 禁止再引入业务「文件浏览器」第三目录(如旧的 `downloads` / `imports` / `exports` 三分法);`html/` 是报告页扫描面,不是第三种文件浏览器
- 缓存、临时、RPA 录屏用 `cache` / `temp` / `videos`,不要当成用户可见的「输出目录」
宿主约定(与本模板对齐):
- 工具栏「导入」**UI 一律**打开宿主「导入数据」面板(默认从 `input/` 选文件);「导出」默认写到 `output/`
- 若表声明了 `replaces: import`,确认导入后由技能 CLI 写库(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「`replaces`」);仅有 `replaces: create` 时宿主可按行调 create(兼容)
- 数据管理扫描 `{skill_data}/html/*.html`(一层、非递归);`<title>` → 侧栏显示名;「重新生成」调用 Action id **`render-html`**(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」)
---
## 3. 两类路径
@@ -54,6 +80,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
技能**创建或更新**的文件:下载结果、导出报表、失败截图、队列 sidecar 等。
- **默认**必须在 `{skill_data_dir}/<子目录>/` 下。
- 用户可拿走的业务产出 → **`output/`**。
- **必须**通过 `resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
### B. 用户显式输入(user-owned)
@@ -61,7 +88,7 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
用户在 CLI / Agent 对话中给出的**已有文件**绝对路径,例如「发布这个视频 `D:\素材\x.mp4`」。
- 技能**只读**,路径可以是用户磁盘任意位置。
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/imports/<相对路径>`,**不**相对 workspace/CWD。
- 使用 `resolve_input_path()`:绝对路径原样;**相对路径**解析为 `{skill_data_dir}/input/<相对路径>`,**不**相对 workspace/CWD。
---
@@ -72,9 +99,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
```ini
# ── 数据目录子路径(见 development/DATA_PATHS.md)──
# 相对路径相对于 {JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/,不是 workspace
# SKILL_DOWNLOAD_DIR=downloads/videos
# SKILL_IMPORT_DIR=imports
# SKILL_EXPORT_DIR=exports
# SKILL_INPUT_DIR=input
# SKILL_OUTPUT_DIR=output/videos
# SKILL_UPLOAD_DIR=uploads
# SKILL_CACHE_DIR=cache
# SKILL_TEMP_DIR=temp
@@ -83,8 +109,8 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
| 规则 | 说明 |
|------|------|
| 未配置 | `{skill_data_dir}/{default_subdir}` |
| 相对值 `downloads/videos` | `{skill_data_dir}/downloads/videos` |
| 绝对值 `D:\nas\exports` | 高级用户/NAS 挂载,原样使用 |
| 相对值 `output/videos` | `{skill_data_dir}/output/videos` |
| 绝对值 `D:\nas\output` | 高级用户/NAS 挂载,原样使用 |
| **禁止** `./outputs/...` | 会随 CWD 漂移,不得作为 `.env.example` 活跃配置行 |
业务专属路径 env key 须带 **`SKILL_` 前缀**(或技能 slug 前缀),避免污染全局命名空间。
@@ -95,20 +121,20 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
```python
from util.runtime_paths import (
get_downloads_dir,
get_imports_dir,
get_input_dir,
get_output_dir,
resolve_data_path,
resolve_input_path,
list_resolved_data_paths,
)
# 下载保存(A 类)
dest = os.path.join(get_downloads_dir(), f"{vid}.mp4")
# 下行产出(A 类)——视频、报表等
dest = os.path.join(get_output_dir(), f"{vid}.mp4")
# 或带 env 覆盖
out_dir = resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads")
out_dir = resolve_data_path("SKILL_OUTPUT_DIR", "output")
# CLI -i urls.txt(B 类,相对路径 → imports/)
# CLI -i urls.txt(B 类,相对路径 → input/)
manifest = resolve_input_path(args.input_path)
# health 诊断
@@ -123,6 +149,8 @@ os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
# ❌ .env.example 里写 ./outputs/videos
DOWNLOAD_OUTPUT_DIR=./outputs/videos
# ❌ 再拆 downloads / imports / exports 三个用户目录
```
---
@@ -138,7 +166,7 @@ DOWNLOAD_OUTPUT_DIR=./outputs/videos
- 失败截图、录屏中间文件:`rpa-artifacts/{batch_id}/`(见 [`RPA.md`](RPA.md) §5.1)
- 录屏成片:`videos/`(见 [`RPA.md`](RPA.md) §5.3)
业务 `downloads/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
业务 `output/` 与 RPA `videos/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
---

View File

@@ -5,7 +5,7 @@
本文默认你开发的是当前最常见的一类业务 skill:
- 有明确的 `scripts/main.py` CLI 入口
- 可能需要读写本地 SQLite(例如任务日志 `task_logs`)
- 可能需要读写本地 SQLite(业务表按需创建,不是默认必有)
- 可能需要调用兄弟技能或外部 HTTP / RPA
- 业务逻辑主要放在 `scripts/service/`
@@ -127,13 +127,15 @@ scripts/
作用:常量、日志、路径、时间工具、通用帮助函数
`util/logging_config.py` 是 `jiangchang_skill_core.unified_logging` 的**薄封装**,业务代码应通过它获取 logger
公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.2` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
公共能力(config、logging、runtime_env、rpa、media_assets、video_session、runtime_diagnostics、activity/SRCP)从共享 runtime 的 `jiangchang-platform-kit>=1.2.11` import,**不得**在 `scripts/` 下保留 `jiangchang_skill_core/` 副本。
## 3.2 开发 RPA 类 skill
若 skill 需要浏览器/桌面/手机自动化,按以下顺序落地:
1. **先读总纲与三份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。
1. **先读总纲与四份标准**:`AI_RPA.md`(职责、合规、风控与完整闭环)、`ACCOUNT_MANAGER.md`(账号 / Profile / 租约 / 多账号 / 查看排障)、`RPA.md`(含 §0.2:Profile 强制 / 有头 / 登录分平台)、`CONFIG.md`(`.env` 落盘与读取)、`ADAPTER.md`(四档 adapter)。
- 写任何界面操作代码前再读 `RPA_ELEMENT_LOCATING.md`,先完成真实拾取、element map、唯一性和后置状态。
- 浏览器读 `RPA_BROWSER.md`;Windows 桌面读 `RPA_DESKTOP.md`;Android/iOS 读 `RPA_MOBILE.md`。
2. **从 examples 选择性复制**(不要整包照搬 `examples/<mode>/`,见各 example README 的 copy map):
- 若是 **真实浏览器 RPA**(真实网站、登录态、验证码、滚动采集),**必须先读** `examples/real_browser_rpa/README.md`,再按需复制到 `scripts/service/`:
- `examples/real_browser_rpa/scripts/service/browser_session.py`
@@ -148,15 +150,16 @@ scripts/
- `examples/simulator_browser_rpa/scripts/service/task_service.py`(async 编排)
- `sandbox/demo_app.html` 仅留在 examples,不进入生产 skill
- **禁止**:`rpa_helpers`、sync Playwright 用于完整技能 RPA 主路径、`task_service` 散落 account-manager subprocess
- **网页硬规则**:未拿到 `profile_dir` 禁止开浏览器;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
- **网页硬规则**:未拿到非空 `profile_dir + lease_token` 禁止开浏览器;一账号一 Profile;生产 `OPENCLAW_BROWSER_HEADLESS=0`;站点是否登录写进 REQUIREMENTS / 用户教程,**不要**写成「可不接 account-manager」
3. **只用共享库,不在 skill 里重写反反爬**:
```python
from jiangchang_skill_core import config
from jiangchang_skill_core.rpa import launch_persistent_browser, anti_detect, wait_for_captcha_pass
```
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。
5. **桌面/手机**:本期标准见 RPA.md 第 2/3 节,复用 `jiangchang_desktop_sdk` / `screencast`,**不要在新 skill 里重复造包**(尚待实战验证)。
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 全链路验证。先读 `examples/real_desktop_rpa/README.md` 与 `development/RPA_DESKTOP.md`(含 Name/Value、最大化激活、同名叶子父容器),复用窗口唯一性、UIA 作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。
6. **手机**:先按 `RPA_MOBILE.md` 完成设备、context、element map 和验收设计。当前 `examples/mobile_rpa/` 只是完成规格,尚无真实成功案例;不得把骨架宣传为已跑通实现。
**技术路线红线:**正常界面或获得授权的官方 API 无法完成闭环时,停止并向负责人说明;禁止 AI 或开发者擅自改用破解、反编译、逆向、私有协议、Hook / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
@@ -167,7 +170,7 @@ scripts/
技能根目录的 `requirements.txt` 是**标准文件**,用于声明本技能**特有** Python 三方依赖。
- **公共依赖**(`jiangchang-platform-kit`、`playwright`、config、runtime diagnostics、RPA 公共能力等)由**宿主共享 runtime** 提供,**不要**写入技能 `requirements.txt`。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.2`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- `SKILL.md` 的 `metadata.openclaw.platform_kit_min_version`(当前 `1.2.11`)是运行契约/兼容性声明,供宿主安装与启用时校验,**不是** pip 依赖声明。
- 匠厂宿主安装/更新技能后,会将技能 `requirements.txt` 安装到共享 venv:`{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。
- **不要**在业务代码中 `subprocess` / `pip install`;缺依赖由 `health` 报错,由宿主负责安装。
- **版本约束尽量收窄**,降低多技能共享 venv 时的冲突风险。推荐范围写法:
@@ -445,10 +448,10 @@ metadata:
宿主 Action 入口 manifest(可选;见 `references/ACTIONS.md`)
- `examples/`
比如 `version` 输出示例、`log-get` 输出示例
比如 `version` 输出示例
- `schemas/`
比如 `skill-actions.schema.json`、日志记录、机读 JSON 的 schema
比如 `skill-actions.schema.json`、机读 JSON 的 schema
不要把正式研发文档放到 `assets/`。
用户市场四 Tab 进根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 编排资料进 `references/`;开发规范进 `development/`。
@@ -463,7 +466,7 @@ metadata:
也就是说,`cli/app.py` 的职责是:
1. 打印帮助
2. 定义 `run / logs / log-get / health / version / init-db`(及业务子命令)
2. 定义 `run / health / version / init-db`(及业务子命令)
3. 把参数转交给 `service.task_service`(或薄适配后再进 domain service)
不要在 `cli/app.py` 里直接写:
@@ -502,7 +505,7 @@ assets/actions.json → placements / bind / executionProfile / entry
`service` 层是**关键业务日志**的落点,须遵循 [`LOGGING.md`](LOGGING.md):
- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`、`log.info("task_log_saved ...")`;失败路径必须 `log.exception("task_failed ...")`。
- `cmd_run` 及主任务入口必须:`log.info("task_start ...")`;失败路径必须 `log.exception("task_failed ...")`。
- 外部 API、兄弟技能、RPA 操作须记录**开始 / 结束 / 耗时 / 状态**(`external_call_start` / `external_call_done elapsed_ms=...`)。
- 长任务 / RPA 须配合 `activity.emit` 或 `RpaVideoSession.add_step` 输出用户可见进度;`cmd_run` 须 `job_context` + 各出口 `finish`(见 [`LOGGING.md`](LOGGING.md) §2.5)。
- 使用 `from util.logging_config import get_skill_logger`,**不要**用 `print` 替代 logger 做排障日志。
@@ -523,10 +526,11 @@ assets/actions.json → placements / bind / executionProfile / entry
如果你的 skill 需要记录日志或本地状态,建议:
- `db/connection.py`
只做连接和建表
只做连接和建表(模板默认只建 `_jiangchang_*` 元数据表;业务表按需 CREATE)
**业务表之间禁止 SQLite `FOREIGN KEY` / `REFERENCES`**:只存业务 id,存在性 / 级联 / 删除在 repository/service 处理(见 [`references/SCHEMA.md`](../references/SCHEMA.md)「表与表之间:禁止 SQLite 外键」)
- `db/task_logs_repository.py`
只做增删查改(模板默认表:`task_logs`)
- `db/<domain>_repository.py`
只做业务表增删查改(有业务表才建;模板不再默认提供任务审计表)
- `db/display_metadata.py`
创建 `_jiangchang_tables` / `_jiangchang_columns` 并幂等写入中文展示名(见 `references/SCHEMA.md`)
- `db/display_metadata_validator.py`
@@ -540,17 +544,20 @@ assets/actions.json → placements / bind / executionProfile / entry
- 打印用户提示
- 拼接业务流程
## 11.5 Skill Action manifest(可选)
## 11.5 Skill Action manifest(按入口定位挂载)
若技能需要出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json`。
先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md):对话 / 数据管理 / 定时任务**平级**;哪个主能力出现在哪个入口,由本技能 UX 在 `REQUIREMENTS` 入口定位表中定义。
若定位表要求出现在宿主**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中,提供 `assets/actions.json` 并写入对应 `placements`。
- **产品入口与何时走哪个**见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)
- **运行时金标准**见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)
- 字段契约见 [`references/ACTIONS.md`](../references/ACTIONS.md)(`placements` / `bind.tables` / `executionProfile` 正交)
- JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)
- 模板最小示例仅暴露 `health` / `version` / `config-path`(均显式 `executionProfile: "sync"`;**不**自动加 toolbar);CLI 另含 `init-db` 供宿主静默建库(不必做成 Action)
- 含 `toolbar` 的 Action:**必须**声明合法 `bind.tables`(`POLICY-SKILL-ACTION-003`)
- 含浏览器 RPA / 长耗时的业务技能:**必须**增加至少一条 `"executionProfile": "async"` 且 `placements` 含 `agent` 的 action(`POLICY-SKILL-ACTION-001`)
- 没有宿主直接操作需求时,**可以删除** `actions.json`;删除后勿保留失效引用
- 入口定位表确认无任何宿主结构化入口需求时,**可以删除** `actions.json`;删除后勿保留失效引用
- scaffold **不会**移除 Schema 中的 `bind` 支持,**不会**生成平台业务 Action 或业务表名;仅替换占位 slug
宿主执行方式:
@@ -583,6 +590,7 @@ cmd_stats() # 只读 sync:JSON,无 job_context
- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。
- **顺序执行、不并发**同一浏览器 Profile / account lease。
- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。
- **HTML 页面报告**(可选):需要侧栏预览整页时,写入 `html/` + Action `render-html`;契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」、[`DATA_PATHS.md`](DATA_PATHS.md);薄写入 helper 见 `scripts/service/html_page_service.py`。
- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。
### 队列 + pick(可选)
@@ -663,18 +671,10 @@ python scripts/main.py <your-command> -h
如果你的 skill 需要本地日志或数据库,再继续:
```bash
python scripts/main.py <your-log-command>
python scripts/main.py <your-detail-command> <id>
python scripts/main.py <your-read-command>
```
如果你沿用了模板中的通用任务日志骨架,那么这里可以具体对应成:
```bash
python scripts/main.py logs
python scripts/main.py log-get 1
```
除 `health` / `version` / `logs` 外,执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索:
执行一次 `run`(或你的主任务命令)后,应确认**统一日志文件**已生成并可检索:
```text
{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log
@@ -822,9 +822,14 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- 安装后状态正常
- 不会出现缺文件、缺入口或安装失败的问题
### 第七步:按声明做宿主多入口验收(已声明的入口都要测;须看用户体验)
### 第七步:按入口定位与声明做宿主多入口验收(须看用户体验)
安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 mock 通就认为完成。宿主侧栏有多条与技能相关的入口;**按本技能 `assets/actions.json` 的 `placements` / `executionProfile` 声明逐项自测**(契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。未声明的入口可以跳过;**已声明却测不到,视为验收失败**。不要只测对话、忽略其它入口。
安装完成后,不要只停留在“已安装”状态,也**不要**只在本地 mock 通就认为完成。对话任务 / 数据管理 / 定时任务在宿主上**平级**(见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md))。先核对本技能 `REQUIREMENTS` **入口定位表**,再按 `assets/actions.json` 的 `placements` / `executionProfile` 对「定位为挂」的入口逐项自测(字段契约见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md))。
- 定位为「挂」却未声明或测不到 → **验收失败**
- 定位为「不挂」且写了理由 → 可跳过该入口
- **禁止**从未做入口定位、却用「未声明所以跳过」交差
- 不要只测对话、忽略定位表中已挂的其它入口
**用户体验(与功能入口并列,后台通了不算完):**
@@ -838,7 +843,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|-------------------------|----------------|--------------|
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
| **数据管理 · 行内** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 行内操作链接可见(需有主键列);点按后入参/行为正确 |
| **数据管理 · HTML 报告** | 写出 `html/*.html`;提供 Action id `render-html` | 侧栏出现报告项;预览正常;右键「重新生成」成功且时间/内容更新 |
| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
@@ -882,7 +888,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag)
- [ ] `.github/workflows/release_skill.yaml` 存在
- [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`)
- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception`
- [ ] 主任务入口有关键日志事件词:`task_start`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3)
- [ ] RPA / 域模块(`scripts/service/<domain>/` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`)
- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`)
- [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md))
- [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测
- [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行)

View File

@@ -1,6 +1,26 @@
# 日志规范
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。
本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。
自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。
---
## 0. 验收铁律(先读)
| 通道 | 给谁看 | 能否单独当排障依据 |
|------|--------|-------------------|
| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** |
| Activity / 任务中心 `emit` / `finish` | 用户进度与终态 | **不能**替代文件日志 |
| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 |
| 业务表 | 用户可见业务结果 | 有业务才建;**不是**默认排障通道 |
硬规定:
1. **没有关键节点文件日志 = 客户现场不可排障。**
2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。
3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。
4. **域模块也要打点**:`scripts/service/<domain>/`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。
---
@@ -24,13 +44,11 @@ OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览
| 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 |
| 存证路径 | 截图、录屏、`video_log`、失败 artifacts |
**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。
---
## 2. 日志分层
技能日志分四层,职责互不替代:
技能日志分三层,职责互不替代:
### 2.1 统一文件日志(开发 / 运维排查)
@@ -104,17 +122,9 @@ RPA 等待用 `interruptible_sleep`,**不要**裸 `asyncio.sleep`(否则暂
Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。
### 2.3 业务表(可选,不是默认排障通道)
### 2.3 `task_logs`(任务结果审计)
**来源**:`db/task_logs_repository.save_task_log`
- 适合 `python scripts/main.py logs` / `log-get <id>` 查询。
- 记录任务最终状态、`result_summary`(可含 `video_path`、`video_log` 等)。
- 面向「这次任务成功还是失败、摘要是什么」,不是逐步 trace。
- **鉴权失败与未捕获异常也必须写入 `task_logs`**,保证 `logs` / `log-get` 排查链不断(见模板 `cmd_run` 示范)。
表结构与字段见 [`references/SCHEMA.md`](../references/SCHEMA.md)。
模板**默认不建**任务审计表。业务结果写入各技能自己的业务表;过程看 `jiangchang.log`,进度/终态看任务中心。
### 2.4 RPA video step / artifacts(用户可见动作与存证)
@@ -122,7 +132,7 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
- video artifact 是**标准可启用能力**,**不保证默认每次生成 MP4**(模板默认 `OPENCLAW_RECORD_VIDEO=0`)。
- **`OPENCLAW_RECORD_VIDEO=0`**:不生成最终录屏,但 RPA / 长任务仍须保留 `RpaVideoSession`、`video.add_step` 与 Activity 进度同步。
- **`OPENCLAW_RECORD_VIDEO=1`**:`result_summary` 应包含 `video_path`、`raw_video`、`video_log`、`video_warnings` 等字段;CLI 可打印录屏路径与诊断。
- **`OPENCLAW_RECORD_VIDEO=1`**:`finish()` 字段应包含 `video_path`、`raw_video`、`video_log`、`video_warnings` 等;CLI 可打印录屏路径与诊断。
- 用户可见的中文步骤(「打开登录页」「点击提交」)。
- 失败截图:`{数据目录}/rpa-artifacts/{batch_id}/...`(受 `OPENCLAW_ARTIFACTS_ON_FAILURE` 控制,默认开)
@@ -130,36 +140,71 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
---
## 3. 必打日志节点清单
## 3. 固定事件词表(生产代码须出现)
以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step)。复制模板后按业务增删,但**不可整体省略**。
日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。
| 阶段 | 建议日志 / emit |
|------|-----------------|
| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) |
| 参数解析完成 | `run_args target_id=... input_id=...` |
| 鉴权开始 | `entitlement_check_start` |
| 鉴权成功 | `entitlement_ok` |
| 鉴权失败 | `entitlement_failed`(`warning`) |
| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` |
| adapter / test target 选择 | `adapter_selected adapter=... system=...` |
| 账号 / profile / lease 获取 | `account_acquired account_id=...` |
| 账号 / lease 释放 | `account_released account_id=...` |
| 外部 API 开始 | `external_call_start system=... operation=...` |
| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` |
| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` |
| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 |
| 打开页面 | step:「打开 xxx 页面」 |
| 检查登录 | step + log |
| 定位输入框 / 点击提交 | step + log |
| 等待结果 | step + log(含 timeout 预期) |
| 批量循环 | `batch_progress current=... total=...` |
| 重试 | `retry_attempt n=... reason=...`(`warning`) |
| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) |
| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) |
| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` |
| 任务成功 | `task_success ...` + `task_log_saved status=success` |
| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` |
### 3.1 长任务通用最低集(所有业务 skill)
| 事件名 | 级别 | 说明 |
|--------|------|------|
| `cli_start` | info | CLI 入口(模板已提供) |
| `task_start` | info | 主任务开始(含 `task_type=`) |
| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 |
| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 |
| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` |
### 3.2 RPA / 桌面 / 浏览器额外最低集
当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求:
| 事件名 | 说明 |
|--------|------|
| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) |
| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) |
| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 |
**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。
### 3.3 其它常见节点(按业务选用)
| 阶段 | 建议事件 |
|------|----------|
| adapter 选择 | `adapter_selected adapter=...` |
| 账号 / lease | `account_acquired` / `account_released` |
| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) |
| 兄弟技能 | `sibling_call_start` / `sibling_call_done` |
| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` |
| 存证 | `artifact_saved` / `video_path=` |
### 3.4 最低可验收日志串(示例)
mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似:
```text
cli_start argv=...
task_start task_type=add-one target_id=...
entitlement_ok task_type=add-one
rpa_start stage=open_window
rpa_start stage=locate_control
rpa_start stage=submit
task_failed error_code=ELEMENT_NOT_FOUND ...
```
成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。
---
## 3.5 必打节点与 Activity 对照
以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。
| 阶段 | 文件日志 | Activity |
|------|----------|----------|
| 任务开始 | `task_start` | `emit("开始…")` |
| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` |
| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` |
| 失败 | `task_failed error_code=` | `finish(failed\|partial)` |
---
@@ -169,7 +214,7 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
| 字段 | 含义 |
|------|------|
| `task_type` | 任务类型(与 `task_logs.task_type` 对齐) |
| `task_type` | 任务类型 |
| `target_id` | 目标标识(平台、店铺、报表类型等) |
| `input_id` | 输入记录 ID |
| `batch_id` | 批次 / 录屏批次 |
@@ -196,7 +241,7 @@ batch_progress current=7 total=20 target_id=store-001
| 级别 | 使用场景 |
|------|----------|
| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度、`task_log_saved` |
| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度 |
| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) |
| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback |
| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** |
@@ -209,7 +254,7 @@ batch_progress current=7 total=20 target_id=store-001
### 禁止记录原始值
以下字段**不得**以明文出现在日志、Activity、task_logs 的 `error_msg` / `result_summary`、stdout:
以下字段**不得**以明文出现在日志、Activity、`finish()` 字段、stdout:
- `password`、`token`、`cookie`、`secret`、`api_key`、`authorization`
- 验证码、动态口令全文
@@ -263,8 +308,6 @@ def cmd_run(target=None, input_id=None):
"erp", "submit", elapsed_ms, "ok",
)
tlr.save_task_log(..., status="success", ...)
log.info("task_log_saved task_type=%s status=success", task_type)
finish(status="success", message="任务完成", skill=SKILL_SLUG)
return 0
except Exception:
@@ -273,7 +316,6 @@ def cmd_run(target=None, input_id=None):
task_type, target, input_id,
)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run")
tlr.save_task_log(..., status="failed", error_msg="...", ...)
finish(status="failed", message="任务执行异常", skill=SKILL_SLUG)
return 1
```
@@ -287,11 +329,10 @@ def cmd_run(target=None, input_id=None):
客户现场或测试机排障,建议按以下顺序(由快到慢、由用户可见到运维细节):
1. **CLI stdout** — 是否有 `ERROR:<CODE>:`、`HINT:`、模板提示文案。
2. **`python scripts/main.py logs`** — 最近任务是否写入 `task_logs`、status 与 `error_msg`。
3. **`python scripts/main.py log-get <id>`** — 单条 JSON,含 `result_summary`(video 路径等)。
4. **统一日志文件** — `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(grep `trace_id`、`task_failed`)。
5. **Run Journal** — `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(宿主 UI 进度来源)。
6. **RPA artifacts** — `rpa-artifacts/` 失败截图;若 `OPENCLAW_RECORD_VIDEO=1` 还有录屏 MP4、`video_log`。未开启录屏时可能只有截图或日志,需要完整录屏请在用户 `.env` 中设置 `OPENCLAW_RECORD_VIDEO=1`。
2. **任务中心 / Run Journal** — `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`(进度与 `finish` 终态)。
3. **统一日志文件** — `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(grep `trace_id`、`task_failed`)。
4. **业务表** — 宿主数据管理中该技能自己的业务结果(有才建)。
5. **RPA artifacts** — `rpa-artifacts/` 失败截图;若 `OPENCLAW_RECORD_VIDEO=1` 还有录屏 MP4、`video_log`。未开启录屏时可能只有截图或日志,需要完整录屏请在用户 `.env` 中设置 `OPENCLAW_RECORD_VIDEO=1`。
长时间无 stdout **不代表卡死**;先看 Run Journal / Activity,再看统一日志。
@@ -304,8 +345,8 @@ def cmd_run(target=None, input_id=None):
| [`RUNTIME.md`](RUNTIME.md) | 统一日志能力来源、`util.logging_config` 薄封装、数据根约定 |
| [`TESTING.md`](TESTING.md) | 日志相关 hard policy 测试(`test_development_policy_guard.py`) |
| [`RPA.md`](RPA.md) | RPA step、`RpaVideoSession`、video / artifacts 标准 |
| [`SCHEMA.md`](../references/SCHEMA.md) | `task_logs` 表结构与查询命令 |
| [`CLI.md`](../references/CLI.md) | `logs` / `log-get` 命令与手工排查入口 |
| [`SCHEMA.md`](../references/SCHEMA.md) | 业务表结构与展示元数据 |
| [`CLI.md`](../references/CLI.md) | 最小命令与手工排查入口 |
| [`CONFIG.md`](CONFIG.md) | 敏感配置不进日志;`.env` 边界 |
| [`DEVELOPMENT.md`](DEVELOPMENT.md) | `service/` 层日志职责与发布前检查清单 |
| [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) | 任务中心 / async Job 与 `job_context` 边界 |

View File

@@ -18,17 +18,21 @@
| POLICY-TESTING-002 | `tests/integration/` 下 Python 测试文件若存在,默认应为 `.sample`,不得进入默认套件 | development/TESTING.md §7 真实联调测试的安全约束、§8 新 skill 的最小测试清单 | hard | 检查 `tests/integration/*.py` 无 `.sample` 后缀 | `tests/test_development_policy_guard.py::TestPolicyTesting002` |
| POLICY-RPA-001 | 不得 import/use account-manager 内部 `rpa_helpers` / `inject_account_manager_scripts_path` / `get_account_credential` | development/ADAPTER.md §兄弟依赖;development/RPA.md §1.7 | hard | 扫描 `scripts/**/*.py` 禁止模式 | `tests/test_development_policy_guard.py::TestPolicyRpa001`(另见 `tests/test_no_rpa_helpers_import.py`) |
| POLICY-RPA-002 | RPA 交付代码不得直接执行 ffmpeg 拼 MP4;应使用 `RpaVideoSession`(允许 `ffmpeg_path` 等诊断字段) | development/RPA.md §5.3 录屏成片标准 | hard | 扫描 `scripts/**/*.py` 禁止 `subprocess` / `os.system` / `os.popen` 直接调用 ffmpeg(`task_run_support.py` 保留豁免) | `tests/test_development_policy_guard.py::TestPolicyRpa002` |
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary` | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
| POLICY-RPA-003 | 即使默认 `OPENCLAW_RECORD_VIDEO=0`,RPA / 长任务模板也必须保留录屏能力:`RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()` 字段 | development/RPA.md §5.3;development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/service/*.py`(service 层整体须含上述接入) | `tests/test_development_policy_guard.py::TestPolicyRpa003` |
| POLICY-RPA-004 | 网页 RPA:`.env.example` 若声明 `OPENCLAW_BROWSER_HEADLESS` 则默认必须为 `0`;`scripts/` 若使用 `launch_persistent_*` 则必须同时存在 `pick_web_account`(先 Profile 再开浏览器;实现上应走 ensure-web);登录不全局强制,但不得跳过 account-manager | development/RPA.md §0.2;development/ADAPTER.md §兄弟依赖 | hard | 解析 `.env.example` + 扫描 `scripts/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyRpa004` |
| POLICY-RPA-005 | 必须保留 AI + RPA 总纲,明确 Agent / RPA 职责、授权范围、禁止破解/逆向/私有协议/安全验证绕过、人工介入、状态等待;真实业务默认动作节奏为 1~3 秒 | development/AI_RPA.md §4–§6;development/RPA.md §0.0/§0.3 | hard(文档与默认配置) / soft(真实行为) | 检查总纲关键边界;解析 `.env.example` 的 `STEP_DELAY_MIN=1.0` / `STEP_DELAY_MAX=3.0` | `tests/test_development_policy_guard.py::TestPolicyRpa005` |
| POLICY-RPA-006 | 浏览器 RPA 必须保留 account-manager 生命周期标准;所有样例账号选择路径都须返回非空 `profile_dir + lease_token`,指定数字账号 ID 不得退化为无租约 `account get` | development/ACCOUNT_MANAGER.md;development/ADAPTER.md §兄弟依赖;development/RPA.md §0.2 | hard | 检查标准文档关键标记;扫描真实/仿真 `account_client.py` 的指定 ID 带租约实现并禁止空租约赋值 | `tests/test_development_policy_guard.py::TestPolicyRpa006` |
| POLICY-PACKAGING-001 | `scripts/**/*.py` 单文件 < 1000 行 | development/DEVELOPMENT.md §3.4 发布打包约束;development/RUNTIME.md §发布打包约束 | hard | 行数统计 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_scripts_py_files_under_pyarmor_line_limit` |
| POLICY-PACKAGING-002 | 文本文件 UTF-8 without BOM,无 `U+FEFF` | development/DEVELOPMENT.md §3.4;development/RUNTIME.md §编码与输出 | hard | BOM / 解码检查 | **已有测试覆盖**:`tests/test_release_packaging_constraints.py::test_text_files_are_utf8_without_bom` |
| POLICY-DOCS-001 | 本矩阵存在且包含上述全部 `policy_id` | 本次规范化约定 | hard | 解析 `development/POLICY_MATRIX.md` | `tests/test_development_policy_guard.py::TestPolicyDocs001` |
| POLICY-LOGGING-001 | CLI 入口必须调用 `setup_skill_logging` | development/LOGGING.md;development/RUNTIME.md | hard | 扫描 `scripts/cli/app.py` 含 `setup_skill_logging` 与 logger info | `tests/test_development_policy_guard.py::TestPolicyLogging001` |
| POLICY-LOGGING-002 | service 主任务入口必须使用 `get_skill_logger` | development/LOGGING.md;development/DEVELOPMENT.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging002` |
| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` |
| POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` |
| POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` |
| POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` |
| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` |
| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` |
| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` |
| POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` |
| POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` |
| POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` |
@@ -58,7 +62,12 @@
| 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 |
| 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` |
| RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 |
| RPA 阶段日志是否「够细」(每个控件) | development/LOGGING.md §3.2 | hard 已要求里程碑事件词与域模块有日志;逐步粒度靠 sample 契约与验收 |
| adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 |
| `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` |
| platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
| platform_kit_min_version >= 1.2.11 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` |
| 宿主按 `bind.tables` 过滤 toolbar 按钮 | references/ACTIONS.md | 待宿主升级后完整生效;模板侧已强制显式绑定 |
| 数据管理技能按钮中文名(`label`)对照 ACTIONS.md 建议表;打开类成功静默 | references/ACTIONS.md「Action 按钮中文名」 | 参考非白名单;行业词允许自创;勿做 label 关键字扫描 |
| Data Policy 未启用(L0)时宿主不得拉 Snapshot / 不得改按钮与行列行为 | references/POLICY.md | soft;宿主实现契约 |
| Data Policy 启用后 fail-closed(禁止回退 L0 全开);写路径 domain 必须再鉴权 | references/POLICY.md §6–§7 | soft;`POLICY-SKILL-AUTHZ-*` 硬门禁可后续加 |
| `actions.mode` 仅 allowlist/open;`hostCapabilities` 省略=不限制;scope `valueFrom` 仅 `actor.userId` | references/POLICY.md §0.1 | soft;Schema 已约束 |

View File

@@ -0,0 +1,172 @@
# 宿主产品入口(对话 / 数据管理 / 定时任务)
本文给**技能开发者**与 **AI 编程助手**读:说明匠厂宿主上几个**平级**业务入口各自干什么、**什么时候走哪个**、以及如何为本技能做入口定位。
字段怎么写见 [`../references/ACTIONS.md`](../references/ACTIONS.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md);任务中心 / sync·async 见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。
**与其它分类正交:**
| 文档维度 | 回答的问题 |
|----------|------------|
| 本文(产品入口) | 用户从宿主哪条路径触发同一能力 |
| Type A–D / 目录分层 | 技能有没有库、会不会调兄弟技能 |
| RPA 四象限 | 外部系统怎么接 |
不要把三者合并成一个大枚举。
---
## 1. 入口是什么(先立规矩)
技能跑在**匠厂宿主**上,不是独立软件。宿主侧栏里的入口,是同一技能能力的不同**触发场景**:
| 今日常见入口 | 触发方式 | 底层 |
|--------------|----------|------|
| **对话任务**(新建任务) | 自然语言 → Agent | 短 CLI,或 `run_skill_action`(`placements` 含 `agent`) |
| **数据管理** | 点工具栏 / 行 / 批量 / 表单(UI) | `run_skill_action`(`source=data-management`)→ **同一 CLI** |
| **定时任务** | 到点「技能直调」或定时 Agent | `run_skill_action`(`cron` / `cron-agent`)→ **同一 CLI** |
**铁律:**
1. **入口平级**:没有「对话主、数据管理/定时辅」。只是触发场景与交互方式不同,各自有优势。
2. **同一业务能力、同一 domain service**:禁止按入口复制逻辑,禁止按 `source` 分叉业务结果。
3. **同能力可挂多个入口**:不是三选一;按本技能 UX 决定挂哪些。
4. **可扩展**:以后若宿主增加新入口,仍是「新触发场景 + 同一内核」,定位方法不变(定位表多一列即可)。
5. **任务中心不是业务入口**:只表示 `executionProfile: async` 的执行壳;与从哪个入口点进来无关。
---
## 2. 什么时候走哪个入口
### 2.1 对话任务——「用嘴发起」
**适合:**
- 用户用自然语言描述意图,条件一次说清或还要追问
- 探索式:「能不能帮我……」「先看看再决定」
- 对象临时指定(贴链接、说一个范围)
- 需要 Agent 解释结果、失败原因、下一步
- 用户不想打开数据管理,只想在聊天里办完
**技术自问:** 用户会不会用一句话说清这件事?需要模型帮理解和确认吗?——会,就为该能力挂 `agent`(长任务 / RPA 必须 Action + 通常 `async`;短查询可用共享 Python CLI)。
**注意:** 若技能还有「对着表运营 / 定时啃队列」等场景,**不要把对话当成唯一路径**,否则传统用户与省 token 路径会丢掉。
### 2.2 数据管理——「用界面发起 + 看着数据做」
数据管理有两层,**同等重要**:
1. **执行面**:工具栏 / 行级 / 批量 / `replaces` 接管新增·编辑·删除·**导入** —— 与对话一样触发同一 CLI,走完整业务闭环(传统用户可不聊天)。导入按钮的 UX 始终是宿主「导入数据」面板;有 `replaces:import` 时确认后由技能写库。
2. **运营面**:业务表、状态流向、配置、input/output、日志、HTML —— 看清数据从哪来、到哪去、卡在哪。
**适合:**
- 在业务数据上操作:看队列、选一行重试、勾选批量、顶栏「处理 N 条 / 同步」
- 已知流程、参数结构化(表单 / 确认 / 一点即跑)——**省 token**,不必每次过模型
- 准备与执行拆开:表里先准备,再在表上点执行
- 服务不愿用 AI 对话的用户
**技术自问:** 用户会不会打开这张表,靠点选就把主流程跑完?——要,就挂 `toolbar` / `row` / `batch` / `replaces`,并做好 `_jiangchang_*` 中文元数据。
**不要:** 把数据管理理解成「只给开发者看 SQLite」;也不要在完全没有结构化操作对象时硬造空表按钮。
### 2.3 定时任务——「到点自动发起」
**适合:**
- 同一能力需按节奏重复:消费队列、定期同步、巡检
- 无人值守:默认参数即可跑,人不在场也正确、安全
- 与对话 / 数据管理挂**同一个** Action,只改触发方式
**技术自问:** 这件事明天能不能在人不盯着时自动再跑一遍?——能,挂 `cron`;不能,定位表写明不挂及理由。
**慎挂:** 每次对象都不同须人指定;强不可逆且须当场确认;主路径依赖验证码 / 人工介入。
### 2.4 场景对照(贴需求用)
| 若用户场景是… | 优先考虑 | 说明 |
|---------------|----------|------|
| 「帮我处理这个……」(临时、口语) | 对话 | 灵活、可追问 |
| 「对着表点一下就跑」 | 数据管理 | 结构化、省 token、传统用户 |
| 「每天自动跑,不用我管」 | 定时 | 无人值守 |
| 「偶尔对话下发,平时表上运营,夜里自动啃队列」 | **多个入口都挂同一能力** | 平级互补,不是重复开发 |
| 「只看结果,从不在表上触发」 | 表可仅展示;执行挂对话/定时 | 由本技能 UX 定 |
---
## 3. 如何为本技能做入口定位(必做)
模板**不**规定「某类技能必须三入口都挂满」。由技术按**本技能干什么 + 用户体验**填写定位表(写入 [`REQUIREMENTS.md`](REQUIREMENTS.md))。
对每个**主能力**(一条业务 CLI / 一个 Action)填一行:
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) |
|---------------------------|------|----------|------|--------------------|
| (例)`download-run-pick` | 挂 | toolbar | 挂 | — |
| (例)`download-video` | 挂 | 不挂 | 不挂 | 对象每次不同,须人指定链接 |
**数据管理列怎么写:** `不挂` / `toolbar` / `row` / `batch` / `replaces` / `仅展示表(无业务按钮)`(可组合)。
**规则只有三条:**
1. 定位为「挂」的入口:必须在 `actions.json`(及必要元数据)中声明,且宿主可测通,并走同一内核。
2. 定位为「不挂」的入口:必须有一句理由;禁止空白「忘了」。
3. 未来新入口:表上多一列,方法不变。
完整 ≠ 每个命令塞满所有入口;完整 = **想清楚用户路径后,该出现的入口都挂上了,且同内核**。
---
## 4. 怎么挂(最小路径)
| 我想让用户… | 主要改什么 |
|-------------|------------|
| 能在对话里触发 | `SKILL.md` 意图路由 + `actions.json` 的 `placements` 含 `agent`(长任务 `async`) |
| 能在数据管理看见结果 / 过程 | 业务表 + `init-db` + `_jiangchang_*`(见 SCHEMA) |
| 能在表上点按钮走闭环 | `placements` 含 `toolbar`/`row`/`batch`,`toolbar` 须 `bind.tables` |
| 能设定时自动跑 | 同一 Action 的 `placements` 含 `cron`;参数适合无人值守 |
细节与 Schema:[`../references/ACTIONS.md`](../references/ACTIONS.md)。运行时:[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md)。
---
## 5. 反例(不要这样做)
- 只做对话 Action,从未填写入口定位表,也未考虑数据管理 / 定时用户路径
- 为 toolbar / cron / agent 复制三套业务实现,或按 `source` 分叉结果
- 把数据管理当成只读调试窗,主闭环只能聊天完成(若 UX 上本应支持 UI)
- 不可逆、须确认的主路径默认挂 `cron` 且无产品确认
- 用「Action 可选 / 定时以后再说」当作「可以不思考入口」
---
## 6. 附录:定位表示例(参考,非强制)
### 示例 A:队列消费类(对话 + 表上处理 + 定时)
| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 |
|--------|------|----------|------|----------|
| 入队 / 准备 | 挂 | 可 replaces 或表新增 | 不挂 | 准备态由人或对话写入 |
| 处理 N 条 pending | 挂 | toolbar | 挂 | 运营点选与无人值守同一内核 |
| 单条立即执行 | 挂 | 可选 row | 不挂 | 对象临时指定 |
### 示例 B:单次确认发布类
| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 |
|--------|------|----------|------|----------|
| 发布一条 | 挂 | 按是否落草稿表决定 | 不挂 | 不可逆,需人确认;不做无人值守 |
### 示例 C:无库短查询
| 主能力 | 对话 | 数据管理 | 定时 | 理由摘要 |
|--------|------|----------|------|----------|
| 查询 | 挂(或短 CLI) | 不挂 | 不挂 | 无本地业务表与重复批跑需求 |
---
## 7. 相关文档
- 需求里填定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md)
- 交活怎么验:[`CHECKLIST.md`](CHECKLIST.md)
- 开发步骤中的 Action 挂载:[`DEVELOPMENT.md`](DEVELOPMENT.md) §11.5、§15
- Agent 侧字段契约:[`../references/ACTIONS.md`](../references/ACTIONS.md)

View File

@@ -9,13 +9,14 @@
| 步骤 | 做什么 | 详见 |
|------|--------|------|
| 1 | **拿到仓库**:① 项目经理在 [自建 Gitea](https://git.jc2009.com/)(`git.jc2009.com`,非 Gitea 官网)开仓并授权后 `git clone`;或 ② 本地已有 `skill-template` 时用 scaffold / 复制出新目录(遵守 Git 红线) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §1、§4 |
| 2 | **先读 AI + RPA 总纲**,理解 Agent / RPA 职责、授权边界、禁止路线、风控和操作节奏 | [`AI_RPA.md`](AI_RPA.md) |
| 3 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
| 4 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
| 5 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
| 6 | 本地单测(mock)通过后,按业务默认 **`real_rpa`** 真实跑通;再 `release.ps1`;看 Gitea CI。**mock 通 ≠ 完成** | [`ADAPTER.md`](ADAPTER.md);[`DEVELOPMENT.md`](DEVELOPMENT.md) §15 |
| 7 | 匠厂安装后,按已声明入口自测,并看**用户体验**(进度、失败提示、有头 RPA 等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) |
| 8 | **交活验收(第三方)**:对照 [`CHECKLIST.md`](CHECKLIST.md) 验收;报告写入仓库根目录 [`acceptance-reports/`](../acceptance-reports/) | [`CHECKLIST.md`](CHECKLIST.md) |
| 2 | **先读宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;为本技能填入口定位表 | [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) |
| 3 | **先读 AI + RPA 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) |
| 4 | 按 [`NAMING.md`](NAMING.md) 确认 slug;**先填** [`REQUIREMENTS.md`](REQUIREMENTS.md)(含入口定位表),再写业务代码 | [`REQUIREMENTS.md`](REQUIREMENTS.md)、[`NAMING.md`](NAMING.md) |
| 5 | 替换标识与占位,按四象限 / examples 实现 `scripts/service/` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §4~§14 |
| 6 | **配置 `developer_ids`(开发自测必做)**:开发期技能在匠厂默认不公开;不加则开发者自己也装测不了。从宿主设置复制用户 ID 写入 `SKILL.md` | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 developer_ids」 |
| 7 | 本地单测(mock)通过后,按业务默认 **`real_rpa`** 真实跑通;再 `release.ps1`;看 Gitea CI。**mock 通 ≠ 完成** | [`ADAPTER.md`](ADAPTER.md);[`DEVELOPMENT.md`](DEVELOPMENT.md) §15 |
| 8 | 匠厂安装后,按**入口定位表已挂**的入口自测,并看**用户体验**(进度、失败提示、有头 RPA 等) | [`DEVELOPMENT.md`](DEVELOPMENT.md) §15;[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);[`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) |
| 9 | **交活验收(第三方)**:对照 [`CHECKLIST.md`](CHECKLIST.md) 验收;报告写入仓库根目录 [`acceptance-reports/`](../acceptance-reports/) | [`CHECKLIST.md`](CHECKLIST.md) |
脚手架与 Git 防串库:[`../tools/README.md`](../tools/README.md)(`scaffold_skill.ps1`)。业务仓也可直接从自建 Gitea clone,见 [`DEVELOPMENT.md`](DEVELOPMENT.md) §4 来源 A。
@@ -25,20 +26,24 @@
开始定制 skill 前,建议按以下顺序阅读:
1. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环
2. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
3. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
4. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
5. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
6. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
7. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
8. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
9. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时
10. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
11. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
12. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
13. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
14. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
1. [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) — **宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;入口定位表
2. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环
3. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障
4. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
5. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准(含入口定位表)
6. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
7. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
8. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
9. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
10. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
11. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时的会话、风控和工程总纲
12. [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) — **所有界面技能必读**:真实拾取、element map、唯一性、降级和 AI 编程约束
13. 按执行端选读:[`RPA_BROWSER.md`](RPA_BROWSER.md) / [`RPA_DESKTOP.md`](RPA_DESKTOP.md) / [`RPA_MOBILE.md`](RPA_MOBILE.md)
14. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
15. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
16. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
17. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
18. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)、[`../references/ACTIONS.md`](../references/ACTIONS.md)。
用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md),不要写进本目录。

View File

@@ -31,7 +31,23 @@
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
## 0.1 AI + RPA 协作与授权边界(涉及外部系统时必填)
## 0.1 宿主入口定位(必填)
先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。对话任务 / 数据管理 / 定时任务在宿主上**平级**;挂哪些由本技能业务与 UX 定,模板不规定「必须三入口都挂满」。
对每个主能力填一行(可增行;「不挂」必须写理由):
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 不挂理由(若不挂) |
|---------------------------|------|----------|------|--------------------|
| (例)`<action-or-cli>` | 挂 / 不挂 | 不挂 / toolbar / row / batch / replaces / 仅展示表 | 挂 / 不挂 | |
说明:
- 数据管理列可写组合(如 `toolbar + row`)
- 「仅展示表」= 用户要看过程/结果,但不在表上触发该能力
- 定位为「挂」的入口须在 `assets/actions.json`(及必要表元数据)中落实,并与对话路径共用同一 domain service
## 0.2 AI + RPA 协作与授权边界(涉及外部系统时必填)
### Agent 设计
@@ -54,6 +70,10 @@
### RPA 风控设计
- 执行端:浏览器 / 桌面 / Android / iOS:
- 真实界面版本、拾取工具和拾取日期:
- element map 文件路径;关键元素是否全部登记:
- 每个关键元素的作用域、主定位器、匹配数量和已验证 fallback:
- 页面 / iframe / 窗口 / 弹窗 / Native / WebView 如何选择:
- 会话、Profile、窗口或设备如何准备与互斥:
- 单批上限、单位时间操作量和最长运行时间:
- 普通动作节奏(真实环境默认 `1~3` 秒):
@@ -63,6 +83,21 @@
- 哪些步骤进入人工处理(验证码、人脸、指纹、短信、U盾等):
- 如何防止重复提交并支持必要的断点续跑:
- 需要保存哪些日志、截图、视频和业务结果:
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
## 0.3 业务行生命周期(有「处理某一行」类能力时必填)
若本技能存在对业务表某一行做生成、触达、私信、发布等**有副作用**的处理,须遵守 [`../references/WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md),并在此声明**本技能状态 → 角色**映射(状态名自定,角色固定):
| 角色 | 本技能状态值(可多个) | 说明 |
|------|------------------------|------|
| 可操作(operable) | (例)新建 / 待生成 / … | 默认唯一可被处理 Action 捞起 |
| 进行中(in_progress) | (例)生成中 / 触达中 / … | 抢占后、结束前 |
| 成功类终态 | (例)已生成 / 已私信 / … | 默认不可再处理 |
| 失败类终态 | (例)失败 / 触达失败 / … | 默认不可再处理;失败原因字段: |
| 跳过类终态 | (例)已跳过 / … | 默认不可再处理 |
无此类处理队列时整节写「不适用」即可。
## 1. 文档目标
@@ -81,7 +116,7 @@
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`。
填写提示:
@@ -99,7 +134,7 @@
- 解析并校验 `<输入数据>` 的关键字段
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
- 输出结构化结果(stdout JSON 或固定文本 + 机读字段)
- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`)
- 写入必要的业务表(见 `references/SCHEMA.md`;无业务表也可)
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
- 完成正式环境发布与安装验证(如适用)
@@ -111,7 +146,7 @@
模板写法:
- 支持 `<命令1>`(如 `run`)
- 支持 `<命令2>`(如 `logs` / `log-get`)
- 支持 `<命令2>`(如 `health` / 业务只读查询)
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API)
- 支持执行 `<核心业务动作>`
- 支持记录 `<日志 / 状态 / 结果>`
@@ -125,13 +160,14 @@
- 支持 `python scripts/main.py init-db`(幂等建库;宿主安装/更新后可静默调用)
- 支持 `python scripts/main.py <your-main-command>`
- 支持核心业务编排(HTTP / 批处理 / 可选 RPA,按四档 adapter 选型)
- 支持写入任务日志(`task_logs`)
- 支持失败可诊断(统一文件日志 + 任务中心 `finish`)
- 完成 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) 入口定位表,并按表落实已挂入口(对话 / 数据管理 / 定时按本技能 UX)
### 可后续扩展
- 支持定时批跑
- 支持失败自动重试
- 支持多 `<目标系统>` 轮询
- 入口定位表中已标明「本期不挂」且写了理由的能力(例如暂不挂定时)
## 5. 非功能要求
@@ -202,22 +238,25 @@
- `health`、`version`、`init-db` 命令执行正常
- `python tests/run_tests.py -v` 必跑测试全部通过(mock / 离线门禁;**mock 通 ≠ 完成**)
- 主命令在业务默认档(模板为 `real_rpa`)真实跑通;不得只 mock 通就交活
- `task_logs` 写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
- 业务表(若有)写入和查询符合 `references/SCHEMA.md`(含 `created_at` / `updated_at` Unix 秒级规范)
- `init_db()` 已写入 `_jiangchang_tables` / `_jiangchang_columns`;用户可见表/字段具备中文 `display_name`
- 字段展示顺序与 `PRAGMA table_info(task_logs)` 的 cid 一致;不依赖 `display_order`
- 字段展示顺序与 `PRAGMA table_info(<业务表>)` 的 cid 一致;不依赖 `display_order`
- `tests/test_display_metadata.py` 通过
- 真实联调样例仍放 `tests/integration/`,默认套件不包含真实外联(与业务 `.env` 默认 `real_rpa` 不冲突)
- `SKILL.md` 的 `developer_ids` 已配置为开发者本人匠厂用户 ID(开发期不公开时,否则本人无法在市场安装自测;取 ID 步骤见 `development/DEVELOPMENT.md` §6)
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
- 安装后按本技能入口定位表与 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15、`PRODUCT_SURFACES.md`);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
- 页面/窗口/设备结构变化时停止并保存截图与 DOM/UIA/XML,不运行时猜 selector
## 10. 开发注意事项
- 只修改当前 skill 仓库,不要改动无关兄弟项目
- 正常界面或获得授权的官方 API 无法完成闭环时,停止并上报;不得擅自改走破解、逆向或绕过安全机制的路线
- 先判断四象限类型(`real_browser_rpa` / `real_api` / `simulator_browser_rpa` / `simulator_api`),再读对应 `examples/*/README.md`
- 涉及界面时再按端阅读 `RPA_ELEMENT_LOCATING.md` 和 `RPA_BROWSER.md` / `RPA_DESKTOP.md` / `RPA_MOBILE.md`
- `cli` 只做参数解析;核心逻辑在 `service`;兄弟 skill 调用集中封装(见 `ADAPTER.md`)
- 发布前完成本地验证、工作流验证和正式环境安装验证(含 `developer_ids` 与宿主多入口)
@@ -251,19 +290,18 @@
## 2. 业务背景
当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 task_logs。
因此需要开发 `your-skill-slug`,自动完成 `<核心动作>`,输出 `<输出结果>`。
## 3. 开发目标
- 解析并校验输入
- 通过 adapter 完成 mock / simulator / real 档位动作
- 输出结构化结果并写入 task_logs
- 输出结构化结果;业务结果写入业务表(有才建)
- 支持失败可诊断
## 4. 功能范围
- 支持 health / version / init-db / run
- 支持 logs / log-get
## 5. 非功能要求
@@ -280,7 +318,7 @@
- 成功 JSON / 摘要
- ERROR: 稳定错误码
- task_logs 记录
- 任务中心终态(`finish`)
## 7. 依赖的兄弟技能或外部系统
@@ -295,9 +333,9 @@
## 9. 验收标准
- 命令可运行;tests/run_tests.py -v 通过
- task_logs 符合 SCHEMA
- 业务表(若有)符合 SCHEMA;过程看运行日志,进度看任务中心
- `developer_ids` 已配置为「你的用户ID」(匠厂设置中复制的正整数)
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 自测
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 与入口定位表自测
## 10. 开发注意事项
- 不修改无关项目;不引入旧模板结构

View File

@@ -4,10 +4,16 @@
先读 [`AI_RPA.md`](AI_RPA.md):Agent 负责理解、确认与编排,RPA 只负责授权范围内的确定性界面执行。RPA 是代替人完成正常业务操作的工具,**不是**破解、逆向、私有协议调用或对抗第三方安全机制的手段。
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
网页账号 / Profile 依赖公开兄弟技能 **account-manager**;浏览器 RPA 原语来自 **jiangchang-platform-kit**。账号设计、查看、租约、多账号和验收标准先读 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md);二者 Gitea 地址、clone 方式与红线见 [`SHARED_REPOS.md`](SHARED_REPOS.md)。
我们开发的各类 skill,本质上都是在替人操作三类界面:**浏览器、桌面软件、手机软件**。三类的底层技术不同,但**工程范式相同**:可控会话(Profile)、有头运行、拟人操作、失败存证、人工兜底。
**开始写界面操作代码前,必须先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md),完成真实拾取、元素清单、唯一性和后置状态设计。** 分端实操:
- 浏览器:[`RPA_BROWSER.md`](RPA_BROWSER.md)
- Windows 桌面:[`RPA_DESKTOP.md`](RPA_DESKTOP.md)
- Android / iOS:[`RPA_MOBILE.md`](RPA_MOBILE.md)
---
## 0. 三端通用范式(先看这个)
@@ -22,7 +28,7 @@
| **步骤间随机等待** | 每两个正常业务动作之间 `random_delay(min,max)`,真实环境默认 **1~3 秒**;状态等待另算,不得用固定睡眠代替 |
| **人工兜底(HITL)** | 滑块 / 短信验证码 / 人脸 / U盾 / 动态口令 → **停下来轮询等人工**,超时报 `ERROR:XXX_NEED_HUMAN`,绝不自动硬闯 |
| **失败存证** | 失败必截图,合规场景全程录屏,统一存 `{数据目录}/rpa-artifacts/{batch_id}/{tag}_{ts}.png` |
| **选择器纪律** | 语义选择器优先(id/name/text/aria);**F12 确认后再写,严禁凭记忆猜 DOM** |
| **选择器纪律** | 先真实拾取并填写 element map;语义/可访问性属性优先;关键动作唯一匹配;**严禁凭记忆猜 selector 或用 first/index 掩盖歧义** |
| **统一错误码** | `ERROR:REQUIRE_LOGIN` / `ERROR:CAPTCHA_NEED_HUMAN` / `ERROR:RATE_LIMITED` / `ERROR:LOGIN_TIMEOUT` 等,见下方错误码表 |
| **步骤可控制** | 每条 RPA 指令 = 一个 `@rpa_step` 或 `video.add_step`;等待用 `interruptible_sleep`;宿主通过 control.json 暂停/继续/停止(SRCP,见 [`LOGGING.md`](LOGGING.md) §2.5) |
| **幂等 / 断点续跑** | 批量操作记录"已处理到第几条",崩溃后能续跑、不重复提交 |
@@ -70,6 +76,9 @@ async def open_login(page):
| **登记 ≠ 登录** | `ensure-web` / `add-web` 只保证有账号记录与 Profile 目录;**不等于**目标站已登录。站点登录仍由本技能 `ensure_logged_in`(或等价)在有头浏览器中完成。 |
| **登录 ≠ Profile** | 「可不登录」**绝不等于**「可以不接 account-manager」。无论目标站要不要登录,网页操作都依赖账号管理下发的 **Profile**。 |
| **登录策略 = required 时** | 每轮业务主路径在 `goto` 后必须过登录门禁 + 验证码/人工等待(`HUMAN_WAIT_TIMEOUT`);**禁止**把会话依赖型采集默认抄成 `optional`。 |
| **账号必须可见可查** | 创建后须能在宿主数据管理、`account list/get` 和实际 `profile_dir` 中核验;不得把“命令没报错”当作创建与登录验收。 |
| **一账号一 Profile** | 不同账号必须使用不同且稳定的 `profile_dir`;不得共用、临时创建或指向本机日常浏览器目录。 |
| **租约贯穿任务** | 所有账号选择路径(包括指定数字 ID)都必须取得非空 `lease_token`;成功、失败、取消和异常均在 `finally` 释放。 |
**反模式(禁止):**
@@ -82,12 +91,24 @@ async def open_login(page):
**参考实现:** `examples/real_browser_rpa/`、`examples/simulator_browser_rpa/`(`pick_web_account` → ensure-web → RPA → `finally release_lease`)。
账号完整生命周期、数据目录、数据管理查看方式、矩阵场景和排障证据见 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)。不得只测试单账号一两次就宣称支持批量或多账号。
---
### 0.3 工业级风控、等待与收尾
**状态等待优先:**页面加载、控件可用、弹窗消失、保存成功、下载完成和业务结果出现,必须等待真实条件并设置超时;禁止用固定 `sleep(3)` 假设成功。
**有界就绪等待(platform-kit ≥ 1.2.11 必用):**
- 业务技能的状态等待统一使用共享 API:`jiangchang_skill_core.rpa.poll_until`(async)或 `poll_until_sync`。
- **禁止**在 service 主路径再发明平行的 `deadline + sleep` / 私有 `_wait_*` 轮询作为标准答案(HITL 专用等待除外,仍可用 kit 的 `wait_for_login` / `wait_for_captcha_pass`)。
- 必须区分:**还在加载**(超时内继续等)vs **真的不是目标页/目标态**(有界等待失败后再判)。
- 超时默认映射 `ERROR:UI_NOT_READY`(或业务等价就绪码);**不要**把「慢」直接判成 `PAGE_TYPE_MISMATCH`、配置门禁失败或 `ERROR:RATE_LIMITED`。
- 超时秒数须允许环境变量 / `.env` 覆盖(慢机器、限频、轻量电源场景可调大)。
- 可选浏览器启动超时:`launch_persistent_browser(..., timeout_ms=...)` 或 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS`;超时码 `ERROR:BROWSER_START_TIMEOUT`。默认不开启,与历史行为兼容。
- `health` 可带只读 `machine_power`(供电/限频提示);**不得**仅凭该信号 hard-fail 全技能。
**操作节奏:**
- 两个正常业务动作之间默认随机等待 `1~3` 秒。
@@ -122,6 +143,8 @@ async def open_login(page):
**选型:Playwright + 系统 Chrome/Edge。** 这是团队验证最充分的一条线(标杆技能 `1688-scrape-contacts`、`receive-order` 已落地)。
元素拾取、定位优先级、iframe/弹窗/Shadow DOM、动态列表和唯一性标准见 [`RPA_BROWSER.md`](RPA_BROWSER.md)。本节继续定义会话、启动、HITL 和工程边界。
### 1.0 生产路径(优先)
| 项 | 标准 |
@@ -270,13 +293,14 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
## 2. 桌面软件(Windows 原生程序)
**选型:pywinauto(UIA backend)为主 + 图像识别兜底。**
**选型:Windows UI Automation(UIA)为主 + 图像识别兜底。** 当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。完整拾取、定位和降级标准见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md)。
桌面端常见于 ERP 客户端、网银控件、银企直连等本地程序。优先走可访问性树(控件 ID/名字),坐标点击只做最后兜底。
| 技术 | 优先级 | 适用 / 说明 |
|------|--------|------|
| **pywinauto(`backend="uia"`)** | ✅ 首选 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
| **uiautomation** | ✅ 已验证首选 | `add-friend-wecom` 真实成功案例使用;直接读取 UIA Window/Control 树 |
| **pywinauto(`backend="uia"`)** | ✅ 兼容 | 基于微软 UI Automation 树,拿控件 AutomationId/Name/ControlType,**稳定、不依赖屏幕坐标**,纯 Python |
| **FlaUI**(经 pythonnet 调 .NET) | 备选 | UIA 拿不到的复杂/自绘控件时更完整,但需引入 .NET 运行时 |
| **Playwright** | 特例 | 目标是 **Electron 套壳应用**(很多新 SaaS 客户端)时,可当浏览器驱动 |
| **PyAutoGUI / SikuliX(图像识别)** | ⚠️ 兜底 | 控件树完全拿不到时(Flash/远程桌面/纯自绘 UI);**靠截图找图+坐标,分辨率/缩放一变就崩**,仅最后手段 |
@@ -290,11 +314,12 @@ from jiangchang_skill_core.rpa.stealth import stealth_enabled, STEALTH_INIT_SCRI
- **禁止侵入**:不注入或 Hook 第三方进程,不修改客户端文件,不反编译或调用未授权私有协议。
```bash
pip install pywinauto # UIA 自动化
pip install uiautomation # 当前真实成功案例
# 或:pip install pywinauto # UIA backend
# 图像兜底:pip install pyautogui opencv-python
```
> 状态:桌面端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
> 状态:UIA 技术路线已经 `add-friend-wecom` 真实项目验证。脱敏、收紧后的参考 example 见 `examples/real_desktop_rpa/`;目标软件 selector 仍必须重新拾取。
---
@@ -302,6 +327,8 @@ pip install pywinauto # UIA 自动化
**选型:Android 用 uiautomator2(或 Appium);iOS 用 Appium + WebDriverAgent(需 Mac)。** 底层都是经 USB 的 ADB / WDA。
设备准备、Appium Inspector/UIAutoDev 拾取、Native/WebView、定位优先级和失败 XML 留证见 [`RPA_MOBILE.md`](RPA_MOBILE.md)。
| 平台 | 技术 | 优先级 | 说明 |
|------|------|--------|------|
| **Android** | **uiautomator2**(python 原生) | ✅ 首选 | ADB over USB,直接拿控件树点击/输入,比 Appium 轻快,纯 Python |
@@ -325,7 +352,7 @@ python -m uiautomator2 init # 初始化设备端 agent
# 或统一走 Appium:pip install Appium-Python-Client(另需 Appium Server)
```
> 状态:手机端选型为推荐标准,**尚待真实项目实战验证**,落地时回补踩坑记录到本文。
> 状态:手机端标准已定义,但**尚无团队真实成功案例**。`examples/mobile_rpa/` 仅是 example 完成规格,不能作为已跑通代码照抄;收到成功案例后再升级状态。
---
@@ -342,11 +369,20 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
| `ERROR:AMBIGUOUS_ACCOUNT` | `--login-id` / `--label` 匹配到多条 | 改用数字 id 或更精确标识 |
| `ERROR:RATE_LIMITED` | 触发频控 | 退避后重试 |
| `ERROR:MISSING_BROWSER` | 未检测到 Chrome/Edge | 提示安装 |
| `ERROR:BROWSER_START_TIMEOUT` | 浏览器启动超时(可选 timeout) | 可重试;检查机器负载/供电;可调大 `OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS` |
| `ERROR:UI_NOT_READY` | 有界等待内页面/控件未就绪 | **默认可重试**;勿与「页类型错误/配置未开」混用;勿轻易标 session fatal |
| `ERROR:ENV_DEGRADED` | 环境降级提示类(限频/供电不足等,技能选用) | 可重试;提示接电源/退出省电/减小并发;**不是**配置错误 |
| `ERROR:DEVICE_NOT_READY` | 手机未连接/未授权 | 检查 USB/ADB |
| `ERROR:WINDOW_NOT_FOUND` | 桌面目标窗口未找到 | 检查程序是否启动 |
业务空结果(如「本页无评论」)用业务码或成功空列表表达,**不要**复用上表账号/登录错误码。
**归因纪律(防误诊):**
- 「未开 `ALLOW_REAL_RPA` / 档位门禁」必须用技能自己的配置类错误码,**禁止**与真实启动失败、启动超时共用一个 hint。
- 页面慢导致的超时优先 `UI_NOT_READY` / 业务就绪码;仅当有明确频控 DOM/文案时才用 `RATE_LIMITED`。
- 是否列入技能 `SESSION_FATAL` / `RETRYABLE` 由技能自定,但 **UI 未就绪 / 启动超时默认不应整批不可重试终止**(除非会话已明确不可恢复,如未登录、验证码人工超时)。
---
## 5. 存证与录屏规范
@@ -385,13 +421,13 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
- **skill-template 默认 `OPENCLAW_RECORD_VIDEO=0`**(开发调试更快,不启 ffmpeg)。
- **`OPENCLAW_RECORD_VIDEO=1` 时启用录屏**:字幕、旁白、背景音、MP4 成片由 platform-kit 统一处理。
- 使用 platform-kit 的 **`RpaVideoSession`**;**skill 不要自行合成视频**(不要自己调 ffmpeg 拼 MP4)。
- **默认关闭 ≠ 可以删除录屏接入**:RPA / 长任务 **service 层**(`scripts/service/*.py`)**必须保留** `RpaVideoSession`、`video.add_step`、video artifact merge 到 `result_summary`(见 `POLICY-RPA-003`)。
- **默认关闭 ≠ 可以删除录屏接入**:RPA / 长任务 **service 层**(`scripts/service/*.py`)**必须保留** `RpaVideoSession`、`video.add_step`、video artifact merge 进 `finish()`(见 `POLICY-RPA-003`)。
- `OPENCLAW_RECORD_VIDEO=0` 时 session **无副作用**(不启 ffmpeg、不写字幕文件、不生成最终 MP4),但 **`RpaVideoSession.add_step` 仍可同步 Activity 进度**。
- 生产留证 / 合规 / 客户现场复现时,由用户在 `.env` 或进程环境变量中开启 `OPENCLAW_RECORD_VIDEO=1`。
- **ffmpeg 是唯一录屏器**(Windows:`gdigrab` + `desktop`)。
- **最终视频**(`OPENCLAW_RECORD_VIDEO=1` 时):`{skill_data_dir}/videos/{skill_slug}_{yyyyMMdd_HHmmss}_{batch_id}.mp4`
- **中间产物**:`rpa-artifacts/{batch_id}/capture.mp4`、`subtitles/`、`logs/` 等。
- 任务完成后 CLI / `result_summary` 应包含 video 字段(开启录屏时有路径;关闭时仍保留 `video` 结构与诊断占位):`video_path`、`raw_video`、`video_log`、`video_warnings`、`music_path`、`voiceover_path`、`audio_warnings`(见 `scripts/service/task_run_support.py`)。
- 任务完成后 CLI / `finish()` 应包含 video 字段(开启录屏时有路径;关闭时仍保留 `video` 结构与诊断占位):`video_path`、`raw_video`、`video_log`、`video_warnings`、`music_path`、`voiceover_path`、`audio_warnings`(见 `scripts/service/task_run_support.py`)。
模板最小示范见 `scripts/service/task_service.py` 的 `_run_template_demo()`。
@@ -399,6 +435,10 @@ skill 退出/抛错统一用 `ERROR:` 前缀 + 稳定码,方便宿主与上层
## 相关文档
- `RPA_ELEMENT_LOCATING.md` — 三端共用的拾取、element map、唯一性、降级和 AI 编程约束
- `RPA_BROWSER.md` — 浏览器 Pick Locator、DOM 作用域和定位质量
- `RPA_DESKTOP.md` — Windows UIA 拾取与已验证桌面路线
- `RPA_MOBILE.md` — Android/iOS 拾取、context、设备与 XML 留证
- `ADAPTER.md` — 真实/仿真 × API/RPA 的四档适配器模式
- `CONFIG.md` — `.env` 配置规范与首次落盘机制
- `RUNTIME.md` — 运行时目录与环境变量约定

123
development/RPA_BROWSER.md Normal file
View File

@@ -0,0 +1,123 @@
# 浏览器 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。本文只定义浏览器端的拾取、定位、验证和常见陷阱;账号、Profile、HITL 和录屏仍以 [`RPA.md`](RPA.md) 为准。
## 目录
1. 拾取工具
2. 拾取步骤
3. 定位优先级
4. 特殊上下文
5. 验证标准
6. 禁止模式
7. example 路由
## 1. 拾取工具
优先使用 Playwright Inspector / Codegen 的 **Pick Locator**,辅以 Chrome/Edge DevTools:
- Pick Locator:点选页面元素,获取 role/label/text/testid 等候选定位器。
- DevTools Elements:确认真实 DOM、属性、父容器、iframe 和 Shadow DOM。
- Playwright trace/screenshot:复核动作发生时的页面状态。
检查器只负责给出候选。复制到生产代码前必须人工确认作用域、唯一性和稳定性。
## 2. 拾取步骤
1. 使用真实 `profile_dir` 和有头浏览器打开目标页面。
2. 手工进入发生动作的准确页面、标签页和弹窗状态。
3. 用 Pick Locator 点选元素,先记录建议 locator。
4. 在 DevTools 中确认:
- accessible role/name
- label、placeholder、可见文本
- 稳定 id/name
- `data-testid`(仅目标系统已提供或自有系统可维护时)
- 所在 iframe、dialog、表格行或卡片
5. 先定位业务容器,再在容器内定位动作元素。
6. 运行 `count()`;关键动作期望 `1`。
7. 验证 `visible`、`enabled`、必要时 `editable/checked`。
8. 执行动作并断言 URL、业务容器、成功提示、单号或状态变化。
9. 刷新页面、关闭重开浏览器后复测。
## 3. 定位优先级
从高到低:
1. `get_by_role(role, name=...)`
2. `get_by_label(...)`
3. 自有系统的稳定 `get_by_test_id(...)`
4. `get_by_placeholder(...)`、`get_by_alt_text(...)`、`get_by_title(...)`
5. 稳定业务文本,优先用于非交互元素或父容器
6. 稳定 `id` / `name` / 短 CSS
7. 容器 + `filter(has_text=...)` / `has=...`
8. 短 XPath
9. 已验证的顺序定位
示例:
```python
dialog = page.get_by_role("dialog", name="订单确认")
submit = dialog.get_by_role("button", name="确认提交")
if await submit.count() != 1:
raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS submit_button")
await submit.wait_for(state="visible")
await submit.click()
await page.get_by_text("提交成功", exact=True).wait_for(state="visible")
```
### 列表与重复按钮
不要直接选择全页第一个“添加”:
```python
row = page.get_by_role("row").filter(has_text="客户A")
button = row.get_by_role("button", name="添加")
```
如果业务键不唯一,先停止并要求补充输入,不要用 `.first` 猜。
## 4. 特殊上下文
### iframe
先确认元素属于哪个 iframe,再使用 `frame_locator`。不得因为主页面找不到就改用坐标。
### 新标签页和弹窗
点击前注册 popup/new page 等待;取得新 Page 后再定位。关闭弹窗不等于业务成功。
### Shadow DOM
Playwright CSS locator 默认可穿透 open Shadow DOM;XPath 不穿透。closed Shadow DOM 无法正常读取时,评估目标应用是否提供可访问性属性,不能注入破解。
### 动态列表
等待列表完成加载后再读取;`locator.all()` 不会等待动态列表稳定。滚动或刷新后重新获取 locator。
### Canvas
先确认是否存在可访问性节点或外层业务控件。完全无 DOM 时才进入局部图像方案,并记录分辨率、缩放和识别区域。
## 5. 验证标准
- locator 在真实目标页面匹配数量符合元素清单。
- 不因语言、窗口宽度、列表顺序或一次刷新就指向不同业务对象。
- 动作前检查可见、可用;动作后等待真实业务状态。
- 登录态至少结合两个证据:例如稳定用户菜单 + 登录按钮不存在,不能只看单个哈希 class。
- 真实和仿真站明确区分:仿真可以使用 `fill()` 加速测试;真实生产输入遵守项目拟人节奏。
## 6. 禁止模式
- 用全页所有 `input` 的尺寸和位置猜搜索框。
- 用 `.first` 绕过严格模式。
- 从 DevTools 复制深层 `nth-child` 或绝对 XPath。
- 只等 URL 含 `search` 就判定结果成功。
- 页面结构变化后动态生成 selector 并继续提交。
- 为方便测试给第三方网站注入 testid。
## 7. example 路由
- 真实网站、登录/HITL/滚动:[`../examples/real_browser_rpa/README.md`](../examples/real_browser_rpa/README.md)
- 自有 sandbox、表单和 adapter:[`../examples/simulator_browser_rpa/README.md`](../examples/simulator_browser_rpa/README.md)
复制 example 前先完成目标站元素清单;example 中的任何 URL、selector 和业务字段都不得跨平台照抄。

189
development/RPA_DESKTOP.md Normal file
View File

@@ -0,0 +1,189 @@
# Windows 桌面 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Windows 桌面首选 **UI Automation(UIA)**。当前已验证成功案例 `add-friend-wecom` 使用 Python `uiautomation`;`pywinauto(backend="uia")` 是兼容方案。标准约束的是 UIA 方法,不强制混用两个库。
## 目录
1. 已验证基线
2. 拾取工具和步骤
3. 定位优先级
4. 窗口与控件规则
5. 输入、点击与等待
6. UIA 不可用时的降级
7. 诊断和留证
8. example 路由
## 1. 已验证基线
真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。
该案例验证了:
- 通过进程/窗口类名/标题找到企业微信顶层窗口。
- 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。
- 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。
- 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。
- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。
- 真实点击后校验后置状态(标题、专用容器、子窗口)。
- 处理独立弹窗与嵌套弹窗。
- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。
example 只提炼通用模式;企业微信名称、窗口类、控件名、列表顺序和业务结果不得复制到其他软件。
## 2. 拾取工具和步骤
可使用影刀元素拾取器、Microsoft Inspect/Accessibility Insights、FlaUInspect 或库自带树打印能力。
操作步骤:
1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。
2. 记录目标进程名、PID、主窗口标题和 Window Class。
3. 用检查器点选目标元素,记录:
- AutomationId
- **Name**(内部 id,可能是 `org_op_new` / `status_layout`)
- **Value**(可见中文文案常在这里,如「通讯录」「新的客户」)
- ControlType / ClassName / FrameworkId
- IsEnabled / IsOffscreen / BoundingRectangle
- 父容器链(含业务子窗/pane 标题)
4. 保存截图和原始 UIA 树/拾取器导出文件。
5. 从长路径中找出稳定窗口、业务父容器和叶子属性。
6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。
7. 写入 element map(Name 与 Value 分开登记)。
8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。
9. 关闭重开软件、切换一次页面后再次验证。
### Name / Value(易错)
- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。
- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。
- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。
### 从拾取结果提炼定位器
影刀等工具可能导出很长的 UIA 路径。不要整条照抄:
```text
原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout
→ … → main_chattext(Value=新的客户)
提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」等其它行)
```
长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。
## 3. 定位优先级
从高到低:
1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane)
2. 目标窗口内 `AutomationId + ControlType`
3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`)
4. 业务父容器 + 稳定子元素属性
5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系**
6. `ClassName/FrameworkId` 辅助组合
7. 有业务依据的 index(仅兜底;须写清父容器与序号含义,禁止「过滤后的列表 [2]」与「父节点 child index=2」混用)
8. UIA 元素实时 BoundingRectangle 派生坐标
9. 局部截图模板/OCR
10. 写死绝对坐标:禁止作为生产方案
顶层窗口或关键按钮出现多个候选时,返回 `ERROR:ELEMENT_AMBIGUOUS`,不得默认使用第一个。
同名叶子(如多处 `wbtn_container`)必须限制在业务父容器内(如 `search_external`),并可用子文案(如 Value=`添加`)校验;禁止点搜索旁通用「+」。
## 4. 窗口与控件规则
### 窗口
- 校验窗口属于预期进程和当前用户会话。
- 主窗口、**应用内业务子 pane**、搜索窗、确认窗分别建立作用域。
- 进入依赖完整侧栏/工具栏的页面时:**激活后最大化**;`activate` **禁止**对已最大化窗口无条件调用 `SW_RESTORE`(会还原成普通窗,导致折叠与坐标漂移)。仅最小化时才 Restore。
- 应用可能重建窗口;页面切换后重新取 HWND 和 UIA root。
- 自动化进程与目标进程权限级别必须兼容;不要为了绕过权限注入目标进程。
- 多开场景必须由账号、PID或明确窗口信息选择,歧义时停止。
### 控件
- 在最小业务父容器中搜索。
- 默认要求唯一匹配。
- 检查可见、启用、未离屏和边界矩形有效。
- 虚拟列表只包含当前实例化元素;滚动后重新枚举。
- 不长期保存 Control 对象。
## 5. 输入、点击与等待
### 点击
1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。
2. 重新定位元素。
3. 检查边界矩形。
4. 使用 UIA Click/Invoke 或正常鼠标点击。
5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。
关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。
UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。
### 输入
- 先点击聚焦。
- 清空并逐字输入,敏感值不写日志。
- 输入后读取 Value/Text 或通过后续业务状态验证。
- 剪贴板可能泄露敏感信息,非必要不使用。
### 等待
优先等待:
- 目标窗口出现/消失。
- 控件出现、启用或状态变化。
- 列表产生业务结果。
- 成功/失败提示出现。
固定 sleep 只作短缓冲,并使用可中断等待;不能作为成功条件。
## 6. UIA 不可用时的降级
先检查:
1. 权限级别和 Windows 会话是否一致。
2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。
3. 控件是否离屏或尚未实例化。
4. 应用是否为 Electron(可评估 Playwright)。
5. 是否为自绘、游戏引擎、远程桌面或安全控件。
6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。
只有控件树确认不可用时才使用图像:
- 固定或记录 DPI、缩放、分辨率和主题。
- 限定目标窗口和搜索区域。
- 记录模板来源、置信度阈值和多匹配处理。
- 匹配不唯一时停止。
- 点击后仍验证业务状态。
U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或坐标绕过。
## 7. 诊断和留证
桌面 `health` / `probe-ui` 至少输出:
- UIA 依赖是否可用。
- 目标进程和主窗口是否存在;是否已最大化(若业务需要)。
- 关键窗口/控件是否各唯一匹配。
- 当前窗口标题、Class、PID。
- DPI/缩放诊断。
example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。
手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。
定位失败保存:
- 目标窗口和全屏截图。
- UIA 树。
- 顶层窗口枚举。
- element map 逻辑名、候选数量和属性。
- 应用版本、Windows版本、DPI和会话类型。
## 8. example 路由
真实成功参考见 [`../examples/real_desktop_rpa/README.md`](../examples/real_desktop_rpa/README.md)。该 example 默认只演示 `probe`、`dry-run` 和 `assisted`;不可逆动作必须由业务技能显式实现确认策略。

View File

@@ -0,0 +1,178 @@
# RPA 元素定位与拾取总标准
> 本文定义浏览器、Windows 桌面和手机三端共用的“元素工程”标准。开始写任何界面操作代码前必须先完成元素拾取、元素清单和定位验证。分端实操分别见 [`RPA_BROWSER.md`](RPA_BROWSER.md)、[`RPA_DESKTOP.md`](RPA_DESKTOP.md)、[`RPA_MOBILE.md`](RPA_MOBILE.md)。
## 目录
1. 核心术语
2. 强制工作流
3. 元素清单
4. 定位器质量规则
5. 动作闭环
6. 降级决策树
7. 失败留证
8. AI 编程约束
9. 交付门禁
## 1. 核心术语
| 术语 | 定义 |
|---|---|
| **拾取** | 使用检查器在真实界面上点选元素,读取 DOM、UIA 或移动控件树属性 |
| **定位器** | 根据稳定属性在运行时重新找到元素的规则 |
| **作用域** | 页面、iframe、窗口、弹窗、父容器、列表行或 WebView 等查找边界 |
| **锚点** | 与目标元素存在稳定业务关系的父级、标题、标签或同一行元素 |
| **后置状态** | 动作完成后必须出现或消失的真实业务状态 |
| **降级** | 主定位器不可用时,改用已验证的备用定位器或图像方案 |
| **界面快照** | 失败现场的截图和 DOM/UIA/XML 控件树 |
## 2. 强制工作流
按以下顺序开发,不得跳过拾取直接猜 selector:
1. 在真实目标版本上手工走通业务流程,记录每一步的前置状态和成功表现。
2. 使用对应端检查器拾取元素,保存截图和原始 DOM/UIA/XML。
3. 识别正确作用域:页面/iframe、进程/窗口、Native/WebView、弹窗或列表行。
4. 为每个关键元素填写元素清单。
5. 按分端优先级生成候选定位器。
6. 验证候选定位器在目标作用域中匹配数量符合预期,关键动作默认必须恰好为 `1`。
7. 验证元素可见、启用、未离屏且边界矩形有效。
8. 执行动作,并等待真实后置状态。
9. 重启目标程序或会话后再次验证;动态列表还要更换数据验证。
10. 记录已验证的软件版本、页面状态、系统缩放/设备信息和日期。
未完成第 2~6 步时,代码只能保留明确的 `TODO: NEED_REAL_UI_INSPECTION`,不得写“看起来可能正确”的 selector。
开发期推荐使用独立仓 [jiangchang-ui-recorder](https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder)(本机常见路径 `D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`,`python run.py`):以 **Raw Event Journal → Action → Element Catalog → 异步 Observation** 录制,优先把 `action_flow.json` 与 `element_catalog.json` 交给 AI 编程工具;原始事件可重新归并,tree/index 作为诊断证据。默认不再让完整页面 dump 阻塞动作采集。本模板仓内的 `tools/ui_inspector` 已移除,勿再从 skill-template 启动。
## 3. 元素清单
每个关键输入、按钮、列表、弹窗、成功状态和错误状态都必须登记。推荐 YAML:
```yaml
logical_name: submit_button
purpose: 提交当前业务单据
surface: browser # browser | windows | android | ios
screen: 订单确认
scope:
kind: dialog
locator: role=dialog,name=订单确认
primary_locator:
strategy: role
value: button,name=确认提交
fallback_locators: []
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 出现订单号且状态为已提交
failure_evidence:
- screenshot
- ui_tree
verified_target_version: "待填写"
verified_at: "YYYY-MM-DD"
```
要求:
- `logical_name` 使用业务语义,不使用 `button1`、`element2`。
- `scope` 不得省略;全页面、全桌面或全设备盲搜不作为默认生产方案。
- `fallback_locators` 只能填写人工验证过的定位器。
- 不可逆动作必须同时登记确认前状态和提交后状态。
- selector 变化时更新 `verified_target_version` 和验证日期。
## 4. 定位器质量规则
### 4.1 必须满足
- 关键动作定位器在目标作用域中唯一。
- 定位器依赖业务语义、可访问性属性或稳定接口标识。
- 每次页面/窗口/列表刷新后重新定位,不长期缓存元素句柄。
- 同一元素的主定位器和 fallback 使用不同的稳定证据,不是同一脆弱路径的轻微改写。
- 多结果是合法业务场景时,先定位业务容器/行,再在容器内定位按钮。
### 4.2 默认禁止
- 用 `.first`、`.nth(0)`、`hwnds[0]`、`instance=0` 消除本应解决的歧义。
- 长绝对 XPath、深层 DOM/UIA 路径和大量 index。
- 哈希 class、随机生成 id、易变化坐标作为唯一依据。
- 扫描所有输入框后按宽度、位置“猜”目标。
- 捕获所有异常后继续尝试随机 selector 或随机坐标。
- UI结构变化后自动生成新 selector 并继续执行不可逆动作。
确有稳定业务顺序时可以使用 index,但必须在元素清单中写明锚点、顺序依据、匹配总数和版本验证结果。
## 5. 动作闭环
每一个动作实现为一个小状态机:
```text
确认前置状态
→ 获取正确作用域
→ 重新定位元素
→ 校验唯一/可见/可用
→ 激活并执行真实输入
→ 等待后置状态
→ 记录成功证据
```
- 缓冲等待只用于改善稳定性,不能替代后置状态。
- 点击后状态不明确时先查询业务状态,禁止再次点击。
- 付款、发送、删除、审批、发布等动作如果无法确认结果,返回 `ERROR:RESULT_UNCONFIRMED`。
- 列表滚动、页面切换、窗口重建和移动端 Activity 切换后重新获取元素。
## 6. 降级决策树
找不到元素时依次检查:
1. 目标应用、页面或设备是否正确启动并处于预期账号。
2. 是否选错页面、iframe、进程、窗口、弹窗、Native/WebView 上下文。
3. 元素是否尚未加载、被遮挡、禁用、离屏或在虚拟列表未实例化区域。
4. 是否因管理员权限、远程会话、设备授权或驱动版本无法读取控件树。
5. 主定位器属性是否变化;对比保存的界面快照。
6. 是否可以用已验证的父容器、业务文案或相对锚点定位。
7. 控件是否为 Canvas、自绘、远程桌面或未暴露可访问性树。
8. 最后才评估局部图像/OCR/坐标方案。
任何一级确认“界面结构变化”时,截图、导出控件树并停止;不得在生产任务中让 AI 临时猜新定位器。
## 7. 失败留证
定位失败至少保存:
- 当前屏幕/窗口截图。
- 当前 DOM、UIA 或移动端 XML。
- 页面 URL、进程/PID/窗口标题或设备 serial、包名、Activity、context。
- 逻辑元素名、作用域、主定位器和已尝试 fallback。
- 每个候选匹配数量、可见/启用/离屏状态和边界矩形。
- 目标应用版本、浏览器版本或设备系统版本。
- 失败阶段、错误码、时间和 batch_id。
敏感输入、Cookie、token、手机号和客户数据必须脱敏。
## 8. AI 编程约束
向 AI 编程工具提供本模板时,必须要求它:
1. 先阅读本总标准和对应分端手册。
2. 先输出业务步骤表和元素清单,再生成代码。
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。
4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。
5. 为每个动作实现前置状态、定位、动作和后置状态。
6. 把诊断探针、界面树导出和失败截图作为实现的一部分。
7. 不把 mock 通过、命令未报错或弹窗关闭当成真实业务成功。
8. 不擅自切换到 Hook、注入、私有协议、验证码破解或其他禁止路线。
## 9. 交付门禁
- [ ] 关键元素均有元素清单和真实拾取证据
- [ ] 每个不可逆动作默认唯一定位
- [ ] 重启会话后定位仍然有效
- [ ] 动态数据或列表顺序变化后不会误点
- [ ] 每个动作有真实后置状态
- [ ] UI结构变化会停止并留证
- [ ] fallback 均已人工验证
- [ ] 截图和控件树不泄露敏感数据
- [ ] 真实主路径至少完整跑通一次
- [ ] example 状态明确区分“已验证成功”和“规范骨架”

141
development/RPA_MOBILE.md Normal file
View File

@@ -0,0 +1,141 @@
# 手机 RPA 元素拾取与定位标准
> 先读 [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md)。Android 首选 uiautomator2 或 Appium UiAutomator2;iOS 使用 Appium + XCUITest/WebDriverAgent。当前模板尚无团队真实成功案例,因此本文是强制设计标准,example 仅为规格骨架,不得宣称已生产验证。
## 目录
1. 状态与边界
2. 设备准备
3. 拾取工具和步骤
4. Android 定位优先级
5. iOS 定位优先级
6. Native/WebView 与动态界面
7. 手势、输入和等待
8. 降级与留证
9. example 路由
## 1. 状态与边界
- Android 标准可用于设计和实现,但交付前必须在授权真机和官方 App 上验证。
- iOS 必须有 Mac、签名可用的 WebDriverAgent 和授权设备。
- 不 Root、不越狱、不 Hook、不修改 APK/IPA、不伪造设备身份。
- 短信、人脸、指纹、系统安全确认交给人工。
## 2. 设备准备
每次运行前检查:
- 唯一设备 serial/UDID;多个设备时不得默认取第一个。
- USB/网络连接和调试授权。
- Android 包名/Activity,或 iOS bundle id。
- 屏幕是否解锁、方向、分辨率和系统弹窗状态。
- 自动化 driver/service 是否可用。
- 设备租约/互斥;同一设备默认串行。
`doctor` 应输出上述信息,但不得输出设备隐私数据。
## 3. 拾取工具和步骤
Android 可使用 Appium Inspector、uiautomator2 的 UIAutoDev/层级导出;iOS 使用 Appium Inspector。
1. 在真机上手工进入准确页面。
2. 刷新 Inspector screenshot 和 page source。
3. 在截图或 XML 树中点选元素。
4. 记录:
- Android:resource-id、content-desc、text、class、package、clickable、enabled、bounds
- iOS:name/accessibility id、label、value、type、enabled、visible
5. 确认当前 context 是 Native 还是正确 WebView。
6. 记录稳定父容器、列表行和业务锚点。
7. 在 Inspector 搜索定位器,确认匹配数量。
8. 写入 element map,保存 screenshot + XML。
9. 重启 App、重新进入页面并复测。
截图上看到的元素不一定存在于控件树;必须以 page source/可访问性树验证,不能直接凭坐标写代码。
## 4. Android 定位优先级
从高到低:
1. 稳定且唯一的 `resourceId`
2. `description/content-desc`(accessibility)
3. 稳定 `text + className`
4. 业务父容器内的子元素组合
5. UiSelector / 相对关系
6. 短 XPath
7. 已验证的 instance/index
8. 图像/坐标
示例:
```python
search = d(resourceId="com.example:id/search_input")
if search.count != 1:
raise RuntimeError("ERROR:ELEMENT_AMBIGUOUS search_input")
search.click()
search.set_text(keyword)
if not d(resourceId="com.example:id/result_list").wait(timeout=10):
raise RuntimeError("ERROR:RESULT_TIMEOUT")
```
第三方 App 没有 resource-id 时,使用可访问性描述、稳定文案和父容器组合;不得修改第三方 APK 添加 id。
## 5. iOS 定位优先级
从高到低:
1. accessibility id / name
2. `type + name/label/value`
3. iOS predicate string
4. iOS class chain
5. XPath
6. 图像/坐标
XPath 依赖完整 XML 且通常较慢,只作后备。多个同名按钮时先定位 navigation bar、cell、alert 或其他业务容器。
## 6. Native/WebView 与动态界面
### Native/WebView
- 先枚举 contexts。
- Native 元素使用移动端控件树。
- WebView 元素切换到正确 web context 后使用 Web 定位器。
- context 数量或名称变化时按包名、URL或页面特征验证,不默认取第一个 WebView。
### 动态列表
- 虚拟列表只暴露屏幕附近元素。
- 每次滚动后重新定位。
- 用业务键/行文案选择条目,不依赖当前 instance。
- 到达末尾必须有明确状态或滚动前后内容对比。
### 系统弹窗
权限、通知、文件选择等可能属于系统包或 SpringBoard;为其建立独立作用域和显式处理策略,不把它们当业务 App 元素。
## 7. 手势、输入和等待
- 点击前确认元素可见、启用且 bounds 有效。
- 输入后读取文本或等待结果状态。
- 滑动以元素/屏幕相对区域描述,不跨设备写死绝对像素。
- 页面切换等待 Activity/view/context 或业务元素变化。
- 后台切前台、横竖屏变化后重新获取元素。
- 人工验证期间可用 scrcpy/Appium Inspector 辅助观察,程序轮询真实状态并有超时。
## 8. 降级与留证
找不到元素先检查 context、Activity、系统弹窗、滚动位置和页面 source。确认控件树未暴露后才评估局部图像/OCR。
失败保存:
- 设备 screenshot。
- page source XML。
- serial 脱敏值、包名、Activity、context 列表。
- 屏幕分辨率、方向和系统版本。
- element map 逻辑名、定位器和匹配数量。
- App 版本、driver 版本和失败阶段。
图像方案必须限定设备矩阵和搜索区域;匹配不唯一时停止。
## 9. example 路由
手机 example 规格见 [`../examples/mobile_rpa/README.md`](../examples/mobile_rpa/README.md)。在团队提供真实成功案例前,该目录只能作为实施清单和验收契约,不能作为“已跑通代码”宣传或直接复制。

View File

@@ -2,7 +2,7 @@
## 共享 Python Runtime
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.2` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。
**skill-template** 及复制出的新技能,公共能力均来自宿主匠厂安装的共享 Python Runtime(`jiangchang-platform-kit>=1.2.11` 及其传递依赖,含 `playwright`)。`jiangchang_skill_core` **不得**在技能仓库内 vendored,应由共享 venv 的 site-packages 提供。
技能根目录 `requirements.txt` **只声明技能特有依赖**;**不要**重复声明 `jiangchang-platform-kit` 或 `playwright`。`SKILL.md` 的 `platform_kit_min_version` 是运行契约,**不是** pip 依赖声明。
@@ -12,7 +12,7 @@ kit **源码仓**(可 clone 学习,勿 vendor)与其它公开公共仓登
| 情况 | 做法 |
|------|------|
| 技能依赖 kit 某次修复/新 API(如 `.env` 空值+行尾 `#` 解析、新 RPA helper) | 把 `metadata.openclaw.platform_kit_min_version` 调到**需要的最低版本**(例如 `"1.2.2"`) |
| 技能依赖 kit 某次修复/新 API(如 `.env` 空值+行尾 `#` 解析、`poll_until`、启动超时) | 把 `metadata.openclaw.platform_kit_min_version` 调到**需要的最低版本**(模板默认 `"1.2.11"`) |
| PyPI/私有源刚发了更新,但本技能行为未依赖 | **不必**仅为「追新」抬版本 |
| 宿主升级时机 | 宿主在技能/宿主声明的最低版本**未满足**时按需升级共享 venv;**不会**仅因源上有更新就后台拉 latest |
@@ -39,7 +39,7 @@ Unix(未注入时):
共享解释器通常位于 `{JIANGCHANG_DATA_ROOT}/python-runtime/.venv`。数据根由宿主注入(见 `jiangchang_skill_core.runtime_env`)。
## Runtime 诊断(platform-kit 1.2.2+)
## Runtime 诊断(platform-kit 1.2.11+;含既有 1.2.2+ 字段)
`health` 命令通过 **`jiangchang_skill_core.collect_runtime_diagnostics`** 输出共享 runtime 诊断,**不在技能内重复实现**。典型字段:
@@ -48,6 +48,7 @@ Unix(未注入时):
- `JIANGCHANG_DATA_ROOT` / `resolved_data_root`
- `media_assets_root`、`ffmpeg_path`、`background_music_*`
- `record_video_enabled`、`runtime_issue[warning|error]`
- `machine_power` — 只读供电/限频探测(探不到则 `probed=false`);**不得**单独作为 hard-fail 依据
`health` 是**只读诊断**,**不会**触发 media-assets 下载或修复。并补充 `env_path` / `env_exists` / `example_path` 配置路径字段。
@@ -57,7 +58,7 @@ Unix(未注入时):
- 用户实际 `.env`:`{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/{skill_slug}/.env`。
- `scripts/main.py` 与 `cli.app.main()` 启动时调用 `util.config_bootstrap.bootstrap_skill_config()`。
- 配置优先级:**进程环境变量** > **用户 `.env`** > **`.env.example` 默认值**。
- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.2`,**不得** vendored `scripts/jiangchang_skill_core/`。
- 公共 `config` / `merge_missing_env_keys` 来自共享 runtime 的 `jiangchang-platform-kit>=1.2.11`,**不得** vendored `scripts/jiangchang_skill_core/`。
## media-assets / ffmpeg / 背景音乐
@@ -104,7 +105,7 @@ RPA 录屏成片(`RpaVideoSession`)、ffmpeg 路径、背景音乐探测均
{...}/{skill_slug}.db
```
**业务文件(下载、导入、导出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
**业务文件(输入、输出、缓存等)** 须落在上述数据目录的标准子目录下,**不得**相对 workspace/CWD 写入。用户可见目录为 `input/`(上行)与 `output/`(下行)。完整子目录树、env 覆盖规则、`resolve_data_path()` 用法见 **[`DATA_PATHS.md`](DATA_PATHS.md)**。
- 业务表使用英文 snake_case;中文表名/字段名写入 `_jiangchang_tables` / `_jiangchang_columns`(见 `references/SCHEMA.md`)。
- 界面字段顺序由 `CREATE TABLE` 列定义顺序决定,**不要**维护 `display_order` 来调整顺序。

View File

@@ -56,9 +56,11 @@ git clone https://git.jc2009.com/client-commons/skill-template.git
| 项 | 说明 |
|----|------|
| **Clone 为了什么** | 读 CLI / `references/INTEGRATION.md`、平台表、租约与错误码;排 `NO_ACCOUNT` / `LEASE_CONFLICT` 等 |
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir`(`ensure-web` / `pick-web`) |
| **为什么必须用** | 支撑真实业务的一账号一 Profile、登录状态持久化、多账号矩阵和并发互斥;禁止浏览器“裸奔” |
| **怎么用(业务技能)** | 经 `scripts/service/account_client.py`(或等价)**subprocess 调 CLI**;网页 RPA 须先拿到 `profile_dir + lease_token`(`ensure-web` / `pick-web`) |
| **红线** | **禁止**业务技能 `import` 其内部 `rpa_helpers` / 散落存密码(见 `POLICY-RPA-001`);对方文档若写「同进程 rpa_helpers」,**以本模板约束为准** |
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`;本模板 [`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
| **创建后查看** | 宿主数据管理;CLI `account list/get`、`lease list`;本地用户数据目录中的 `account-manager.db` 与 `profiles/` |
| **深入** | 对方仓 `references/INTEGRATION.md`、`CLI.md`、`SCHEMA.md`;本模板 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md)、[`RPA.md`](RPA.md) §0.2、[`ADAPTER.md`](ADAPTER.md) |
```powershell
git clone https://git.jc2009.com/client-commons/account-manager.git
@@ -104,6 +106,7 @@ git clone https://git.jc2009.com/client-jiangchang/jiangchang-platform-kit.git
|------|----------|
| 不知道新技能目录/发布/developer_ids 怎么做 | **skill-template** `development/` |
| 定 `platform_key`、ensure-web、租约、凭证存储 | **account-manager** + 本模板 RPA/ADAPTER |
| 账号创建后不知道去哪看、登录态不保持、多账号互相污染 | 本模板 **ACCOUNT_MANAGER.md** + account-manager 数据管理 / CLI |
| 看 `emit` / `finish` / `@rpa_step` / `launch_persistent_browser` 实现 | **jiangchang-platform-kit** |
| 技能发布 CI / reusable workflow 从哪来 | **jiangchang-platform-kit** `.github/workflows/` |
| 「公共东西到底在哪」 | **本页登记表** |

View File

@@ -2,12 +2,14 @@
本文是匠厂宿主 **Skill Action / 任务中心** 的技能侧金标准。涉及浏览器 RPA、长耗时任务、数据管理按钮、定时任务或 Agent 直接调用时**必读**。
**产品入口(平级关系、何时挂哪个)**先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md);本文管 sync/async、任务中心与编排。
**边界:**
| 文档 | 职责 |
|------|------|
| 本文 | sync/async、任务中心、Agent 禁令、单内核编排、队列 pick、同步 vs 导出 |
| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、inputSchema |
| [`../references/ACTIONS.md`](../references/ACTIONS.md) | `actions.json` 字段、placements、`bind.tables`、inputSchema、跨表 Lookup、字段条件显隐 `visibleWhen`、**按钮中文名 `label` 参考** |
| [`LOGGING.md`](LOGGING.md) | `job_context` / `emit` / `finish` / Run Journal |
| [`DATA_PATHS.md`](DATA_PATHS.md) | 下载/导入等落盘路径 |
@@ -19,7 +21,7 @@
1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。
2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。
3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。可选 **`hoverHint`**(见 ACTIONS.md):数据管理工具栏悬停;省略 = 不显示,不回退说明或确认黄条。可选 **`visibleWhen`**(见 ACTIONS.md):Action 参数表单字段条件显隐;省略 = 字段始终平铺;**勿与** Policy L3 行态 `visibleWhen` 混淆。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格语义标色;省略 = P0。
4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。
5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。
6. **进度走 Journal** — `emit` / `video.add_step` → Run Journal;UI / Skill Run Card / 任务中心展示;**不靠 stdout 流**。
@@ -38,7 +40,7 @@
| 值 | 宿主行为 | 超时 | 任务中心 |
|----|----------|------|----------|
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **30s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
| **`sync`**(或省略时的宿主缺省) | 调用方等待 CLI 结束,直接返回结构化结果或结构化错误;**不**创建后台 Job | 默认约 **60s**(`SKILL_ACTION_SYNC_TIMEOUT_MS`) | ❌ 不进 |
| **`async`**(须显式声明) | 后台 spawn Job,立即返回 `jobId`;`emit` / `checkpoint` / `finish`;长任务应支持暂停/停止/取消检查 | Job 本身无默认墙钟上限 | ✅ 进入 |
直接执行 CLI:终端等待进程结束。宿主经 Action 调用同一 CLI:再按 `executionProfile` 分流。技能不得自建任务中心。
@@ -48,7 +50,7 @@
| 场景 | 建议 |
|------|------|
| 浏览器 / Playwright / RPA 主路径 | `async` + 建议 `confirmation` |
| 单次可能超过 ~20s 的网络/下载/同步 | `async` |
| 单次可能接近或超过约 60s 的网络/下载/同步 | `async` |
| `health` / `version` / `config-path` / 纯统计 / 秒级查询 | `sync` |
| 任意 placement(含 toolbar) | 允许 sync 或 async;以 manifest 为准 |
@@ -66,7 +68,7 @@
|-------------------------|-------------------|--------|--------------|
| **新建任务**(对话 Agent) | `agent` | `placements` 含 `agent`;`run_skill_action` | 侧栏「新建任务」→ 对话触发 |
| **数据管理 · 顶栏** | `data-management` | `toolbar` + 必填 `bind.tables` | 侧栏「数据管理」→ 表顶栏按钮 |
| **数据管理 · 行内** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 同行操作链接(需主键) |
| **数据管理 · 行级** | `data-management` | `row`(建议 `bind.tables` + `inputMapping`) | 操作列和/或**行右键**(及可选「⋯」)可见并触发(需主键);技能配什么就显示什么 |
| **数据管理 · 批量** | `data-management` | `batch` | 勾选行后点批量按钮 |
| **定时任务** | `cron` | `placements` 含 `cron` | 侧栏「定时任务」→「技能直调」 |
| **任务中心** | (各入口触发的 Job) | `executionProfile: "async"` | 侧栏「任务中心」查看进度 / 取消;**不是** placement |
@@ -141,7 +143,7 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
| 能力 | 约定 |
|------|------|
| 队列表 | `status`: `pending` → `processing` → `success` / `failed` |
| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/imports/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) |
| 入库 | 数据管理 CRUD,或 `import-*`(相对 `{skill_data}/input/`,见 [`DATA_PATHS.md`](DATA_PATHS.md)) |
| 消费 | `run --pick N`,默认仅 `pending` |
| 重试 | `reset` 或数据管理改 `pending`;**failed 不要自动重试** |
| 批次 | 可选 `batch_id`,便于筛选与任务中心对照 |
@@ -164,9 +166,10 @@ cmd_stats() # 只读 sync:JSON stdout,无 job_context
|------|------|--------|
| 同步 / refresh / collect | 外部来源 → 技能本地 SQLite | 技能 domain service |
| 导出当前数据表 | 本地库 → CSV/Excel | **宿主**数据管理统一导出 |
| 特殊业务报告 | 多表合并 / 特殊格式 / 附加计算 | 仅系统导出不足时提供 `export-*` |
| 特殊业务报告(文件) | 多表合并 / 特殊格式 → `output/` | 仅系统导出不足时提供 `export-*` |
| HTML 页面报告 | 写入 `{skill_data}/html/*.html` | 技能生成;宿主扫描预览;重新生成 Action id 固定为 **`render-html`** |
同步 Action 默认写库、不写导出文件。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。
同步 Action 默认写库、不写导出文件。HTML 报告契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」与 [`DATA_PATHS.md`](DATA_PATHS.md)。通用幂等 / 事务 / 空结果保护见 [`../references/SCHEMA.md`](../references/SCHEMA.md)。
---

View File

@@ -22,12 +22,12 @@
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` / `logs` / `log-get` 冒烟;
- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` 冒烟;
- 架构守护:无 `scripts/jiangchang_skill_core/`、`platform-kit>=1.2.0` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- **真实 subprocess**:[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths` 与 **`JIANGCHANG_*` 隔离**;
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py);
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- SQLite 骨架:`init_db` 幂等创建元数据表,默认不建业务表;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py`、`test_data_management_contract.py`);
- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`);
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`);
@@ -198,11 +198,14 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) 中的 `POLICY-LOGGING-*` 规则会检查 logging **hard policy**(见 [`POLICY_MATRIX.md`](POLICY_MATRIX.md)):
- CLI 是否调用 `setup_skill_logging`(`POLICY-LOGGING-001`)
- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`)
- `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`(`POLICY-LOGGING-002` / `003`)
- 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`)
- 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`)
- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`)
- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`)
- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`)
**局限**:自动检测只覆盖可静态判断的**底线**,**不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。
**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。
新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。
@@ -239,7 +242,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
## 12. RPA / video 测试标准
- 模板 `.env.example` 默认 **`OPENCLAW_RECORD_VIDEO=0`**;默认必跑测试**不应启动真实 ffmpeg**(patch session 或显式设 `OPENCLAW_RECORD_VIDEO=0`)。
- **但测试必须守护录屏接入能力**(`POLICY-RPA-003` 扫描 `scripts/service/*.py`):`RpaVideoSession` 被构造、`video.add_step` 被调用、video artifact 结构 merge 进 `result_summary`(`video` / `video_path` 等字段),与默认是否录屏无关。
- **但测试必须守护录屏接入能力**(`POLICY-RPA-003` 扫描 `scripts/service/*.py`):`RpaVideoSession` 被构造、`video.add_step` 被调用、video artifact 结构 merge 进 `finish()`(`video` / `video_path` 等字段),与默认是否录屏无关。
- 断言 `title` / `closing_title` 是**中文**业务文案。
- 断言 step 文案贴近用户动作,不是技术日志(如「准备执行示例任务」而非「enter cmd_run」)。
- 若需测试**真实录屏**(ffmpeg 成片),必须显式设 `OPENCLAW_RECORD_VIDEO=1`,并放在 `tests/integration/` 或手动触发场景,**不得**进入默认 unittest 套件。
@@ -262,7 +265,7 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t
- [ ] `requirements.txt` **不含** `jiangchang-platform-kit` / `playwright`
- [ ] 无 `scripts/jiangchang_skill_core/` vendored 副本
- [ ] `platform_kit_min_version` **>= 1.2.2**(`SKILL.md` + `constants.py`)
- [ ] `platform_kit_min_version` **>= 1.2.11**(`SKILL.md` + `constants.py`)
- [ ] `health` 能输出 `platform_kit_version_ok`(或等价诊断行)
- [ ] `config-path` 可输出用户 `.env` 路径 JSON
- [ ] `pytest.ini` 存在且 `python_files` 只收集 `test_*.py` / `*_test.py`

View File

@@ -8,14 +8,16 @@
根 `README.md`、`SKILL.md`、`references/`、`development/` 仍按模板主目录规则编写,**不要**从 `examples/` 复制。
## 四类外部交互方式
## 外部交互示例矩阵
一级目录固定为四种模式(不可删除、不可移动):
adapter 仍是 `真实/仿真 × API/RPA` 四档;RPA 再按浏览器、Windows 桌面和手机三种界面拆分。原四类目录不可删除、不可移动,桌面和手机目录用于补足界面维度。
| 目录 | 类型 | 状态 | 适用场景 |
|---|---|---|---|
| `real_browser_rpa` | 真实浏览器 RPA | **已有案例** | 真实第三方网站、登录态、人工验证、滚动采集 |
| `simulator_browser_rpa` | 仿真浏览器 RPA | **已有案例** | 自有 sandbox、可控页面、表单 RPA、adapter 分层 |
| `real_desktop_rpa` | 真实 Windows 桌面 RPA | **已有成功来源并已提炼** | UIA 拾取、窗口/控件、probe、dry-run/assisted |
| `mobile_rpa` | Android/iOS RPA | **仅规格,待成功案例** | 设备、控件树、Native/WebView、example 完成契约 |
| `real_api` | 真实 API | **占位** | 真实系统接口、token、权限、限流(尚未沉淀可复制实现) |
| `simulator_api` | 仿真 API | **占位** | mock/sandbox API、无需浏览器(尚未沉淀可复制实现) |
@@ -44,6 +46,8 @@
|---|---|
| 真实网站 + 浏览器 + 登录/验证码/滚动 | **real_browser_rpa** |
| 仿真页面 + 浏览器 + 表单/批量提交 | **simulator_browser_rpa** |
| Windows 原生软件 + UIA | **real_desktop_rpa** |
| Android/iOS App | **mobile_rpa**(当前仅规格,不可作为成功实现参考) |
| 真实系统 API | **real_api**(当前占位,不可作为实现参考) |
| mock / sandbox API(无浏览器) | **simulator_api**(当前占位,不可作为实现参考) |
@@ -60,7 +64,8 @@ examples/<mode>/
tests/
```
- 仅 `simulator_browser_rpa` 可额外包含 `sandbox/`
- `simulator_browser_rpa` 可额外包含 `sandbox/`
- `real_desktop_rpa` 可额外包含脱敏的 `assets/element-map` 与 selector capture
- 仅未来确实需要样例数据时可加 `fixtures/`
- **不要**在 `examples/<mode>/` 下要求或添加:`SKILL.md`、根市场 `README.md`、`references/`、`development/`、`assets/`、`.github/`、release workflow 等完整 skill 仓库层文件
@@ -76,6 +81,8 @@ examples/<mode>/
- 真实浏览器 RPA → 参考 `examples/real_browser_rpa/`
- 仿真浏览器 RPA → 参考 `examples/simulator_browser_rpa/`(adapter 分层权威参考:`scripts/service/adapter/`)
- Windows 桌面 RPA → 参考 `examples/real_desktop_rpa/`(真实成功来源:`add-friend-wecom`)
- 手机 RPA → 先按 `examples/mobile_rpa/` 定义完成标准;真实成功案例补齐前不得照骨架宣称跑通
- `real_api/`、`simulator_api/` 当前为规划占位,**不得**作为实现参考
> 示例提供的是**参考架构与边界**,不是业务代码原样复制。复制前先读对应 README 的 copy map 与「禁止照抄」章节。

View File

@@ -0,0 +1,94 @@
# Mobile RPA Example Specification
本目录定义 Android/iOS example 必须具备的结构和验收契约。**当前没有团队真实成功案例,因此本目录不是可复制实现,也不得宣称已跑通。**
先读:
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_MOBILE.md`](../../development/RPA_MOBILE.md)
## 为什么现在不放伪代码实现
移动端 selector、driver、Activity/context、权限弹窗和设备兼容性必须在真实授权设备上验证。没有成功案例时放一套看似完整的代码,会诱导开发者和 AI 猜 `resource-id`、默认取第一个设备或用坐标硬点,违背模板目标。
## 未来成功 example 的固定结构
```text
mobile_rpa/
README.md
android/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
ios/
README.md
assets/element-map.yaml
scripts/service/device_session.py
scripts/service/mobile_locator.py
scripts/service/example_flow.py
tests/test_locator_contract.py
tests/integration/test_real_device.py.sample
```
只有对应端出现真实成功案例后才创建代码文件。
## Android example 完成标准
- 唯一设备 serial 和设备互斥
- doctor:ADB授权、包名、Activity、屏幕状态、driver
- Appium Inspector/UIAutoDev 拾取步骤和原始 XML
- element map:resource-id/content-desc/text/class/父容器
- Native/WebView context 选择
- 点击、输入、滑动和真实后置状态
- 权限弹窗、系统弹窗和人工验证
- screenshot + page source 失败留证
- 虚拟列表滚动后重新定位
- 至少一台授权真机完整跑通
## iOS example 完成标准
- Mac、WebDriverAgent签名和设备 UDID 准备
- doctor:WDA、bundle id、系统版本、屏幕状态
- Appium Inspector 拾取和原始 page source
- accessibility id/name、predicate/class chain 定位
- Native/WebView context 选择
- 系统 alert 和人工验证
- screenshot + page source 失败留证
- 至少一台授权真机完整跑通
## element map 最小字段
```yaml
logical_name: login_button
surface: android
screen: 登录页
scope:
package: com.example.app
activity: .LoginActivity
primary_locator:
strategy: resource_id
value: com.example.app:id/login
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 首页业务标记出现
verified_device: 待填写
verified_app_version: 待填写
```
## 状态升级规则
满足以下全部条件后,才可把本目录状态改为“已有成功案例”:
1. 提供用户确认的成功项目来源。
2. 在真实授权设备上跑通完整业务闭环。
3. 保存脱敏 element map 和失败证据样例。
4. 有默认离线单测和显式真实集成测试。
5. 经过设备重连、App重启和页面变化验证。
6. 用户人工验收通过。

View File

@@ -33,6 +33,8 @@ real_browser_rpa/
│ └── util/
│ ├── constants.py # 平台常量、selector 占位
│ └── logging.py # 结构化日志与脱敏
├── assets/
│ └── element-map.sample.yaml # 元素清单模板;真实拾取后填写
└── tests/
├── test_human_verification.py # 验证码误判防护
└── test_stop_reason.py # 停止原因文案
@@ -55,12 +57,13 @@ real_browser_rpa/
## 开发新 skill 时如何复制
1. **先阅读**本 README 及下方「Copy map」「复制边界」章节
2. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
3. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
4. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
5. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
6. 在 `task_service.py` 中接入真实 DB、任务日志、RpaVideoSession
7. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
2. **先完成元素工程**:阅读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`,在真实站用 Pick Locator/F12 拾取并填写 element map
3. 按 copy map **选择性复制**到新技能 `scripts/`(**不要**整包照搬 `examples/real_browser_rpa/`)
4. 修改 `util/constants.py` 中的 `TARGET_PLATFORM`、`DEFAULT_START_URL`、selector 常量
5. 替换 `task_rpa.py` 中的登录态 marker、搜索框/结果解析逻辑(**必须 F12 实测**)
6. 替换 `human_verification.py` 中的验证码 selector(若目标站点 DOM 不同)
7. 在 `task_service.py` 中接入真实 DB、RpaVideoSession
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
## Copy map(复制到新技能 `scripts/`)
@@ -73,6 +76,7 @@ real_browser_rpa/
| `examples/real_browser_rpa/scripts/service/task_service.py` | `scripts/service/task_service.py` | 参考并合并编排层 |
| `examples/real_browser_rpa/scripts/util/constants.py` | `scripts/util/constants.py` | 平台常量与 selector(须替换) |
| `examples/real_browser_rpa/scripts/util/logging.py` | `scripts/util/logging.py` | 可选,按需合并 |
| `examples/real_browser_rpa/assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实 Pick Locator/F12 拾取后填写 |
| `examples/real_browser_rpa/tests/` | `tests/` | 可按需复制测试思路 |
| `examples/real_browser_rpa/README.md` | — | **只读参考**,不复制为新技能根 README |
@@ -112,7 +116,6 @@ real_browser_rpa/
- `_parse_item_locator()` 业务字段解析
- 用户可见中文文案
- 数据库写入逻辑
- 任务日志逻辑
- 录屏/截图业务标题
### 禁止照抄的
@@ -121,6 +124,8 @@ real_browser_rpa/
- 不要照抄平台名、slug、logger 名
- 不要依赖 `D:\OpenClaw\client-commons\account-manager\scripts\main.py` 等开发机绝对路径作为生产依赖
- 不要把示例里的 result selector 当成真实站点 selector
- 不要复制“扫描所有 input 再按宽度/位置打分”的诊断式猜测作为生产主定位器
- 不要用 `.first` / `.nth` 绕过多匹配;关键动作必须先解决唯一性
- 不要把示例代码原样发布为业务 skill
- 不要把真实账号、手机号、cookie、token 写入代码或 README
@@ -169,7 +174,7 @@ real_browser_rpa/
| `human_verification.py` | `SLIDER_SELECTORS`、`SMS_SELECTORS`、`DIALOG_TEXT_CONTAINERS` |
| `task_rpa.py` | `LOGGED_IN_SELECTORS`、`LOGGED_OUT_SELECTOR`、`_parse_item_locator()` |
| `account_client.py` | `PLACEHOLDER_PLATFORM`、`LEASE_HOLDER`、账号 setup 文案 |
| `task_service.py` | 接入 DB、任务日志、entitlement、视频留痕 |
| `task_service.py` | 接入 DB、entitlement、视频留痕 |
## 依赖说明

View File

@@ -0,0 +1,45 @@
surface: browser
status: placeholder_requires_real_site_inspection
elements:
- logical_name: search_input
purpose: 输入搜索关键词
scope:
kind: main
business_container: 待真实拾取
primary_locator:
strategy: role_or_label
value: 待真实拾取
fallback_locators: []
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition:
description: 搜索结果容器出现
verified_target_version: 待填写
- logical_name: search_button
purpose: 提交搜索
scope:
logical_parent: search_form
primary_locator:
strategy: role
value: button,name=搜索
fallback_locators: []
expected_count: 1
postcondition:
description: 结果页或业务错误状态出现
verified_target_version: 待填写
- logical_name: result_container
purpose: 承载搜索结果
scope:
kind: main
primary_locator:
strategy: stable_business_container
value: 待真实拾取
fallback_locators: []
expected_count: 1
postcondition:
description: 列表项、空结果或结束标记可区分
verified_target_version: 待填写

View File

@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
return _validate_pick_payload(payload)
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
@@ -271,7 +329,7 @@ def pick_web_account(
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id(platform_key, int(aid_raw))
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):

View File

@@ -12,6 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.activity import interruptible_sleep
from jiangchang_skill_core.rpa import poll_until
from service.browser_session import close_browser_context, get_start_url, start_browser_session
from service.human_verification import (
@@ -188,42 +189,22 @@ async def _ensure_logged_in(page, *, wait_sec: int) -> None:
async def _find_search_input(page) -> Tuple[Optional[Any], str]:
for sel in SEARCH_INPUT_SELECTORS:
try:
loc = page.locator(sel).first
if await _visible(loc):
loc = page.locator(sel)
count = await loc.count()
if count == 1 and await _visible(loc):
return loc, sel
if count > 1:
logger.warning("search_input_ambiguous selector=%s count=%s", sel, count)
except Exception:
continue
candidates: List[Tuple[int, Any, str]] = []
try:
inputs = await page.locator("input").all()
except Exception:
inputs = []
for inp in inputs:
try:
if not await inp.is_visible():
continue
box = await inp.bounding_box()
if not box or box.get("width", 0) < 120:
continue
placeholder = await inp.get_attribute("placeholder") or ""
score = box.get("width", 0)
if "搜索" in placeholder:
score += 500
if box.get("y", 9999) < 200:
score += 200
candidates.append((score, inp, f"input placeholder={placeholder!r}"))
except Exception:
continue
if candidates:
candidates.sort(key=lambda x: x[0], reverse=True)
_, inp, sel = candidates[0]
return inp, sel
try:
ph = page.get_by_placeholder(re.compile("搜索"))
if await ph.count() > 0 and await ph.first.is_visible():
return ph.first, "placeholder=搜索"
count = await ph.count()
if count == 1 and await ph.is_visible():
return ph, "placeholder=搜索"
if count > 1:
logger.warning("search_input_ambiguous selector=placeholder_search count=%s", count)
except Exception:
pass
return None, ""
@@ -232,31 +213,44 @@ async def _find_search_input(page) -> Tuple[Optional[Any], str]:
async def _find_search_button(page):
try:
btn = page.get_by_role("button", name="搜索")
if await btn.count() > 0 and await btn.first.is_visible():
return btn.first
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning("search_button_ambiguous selector=role_button_search count=%s", count)
except Exception:
pass
try:
btn = page.locator(SEARCH_BUTTON_SELECTOR).first
if await btn.count() > 0 and await btn.is_visible():
btn = page.locator(SEARCH_BUTTON_SELECTOR)
count = await btn.count()
if count == 1 and await btn.is_visible():
return btn
if count > 1:
logger.warning(
"search_button_ambiguous selector=%s count=%s",
SEARCH_BUTTON_SELECTOR,
count,
)
except Exception:
pass
return None
async def _wait_search_results(page, timeout_sec: float = 45.0) -> bool:
deadline = time.monotonic() + timeout_sec
while time.monotonic() < deadline:
"""有界等待搜索结果容器出现(kit poll_until;区分加载中与超时未就绪)。"""
async def _ready() -> bool:
try:
url = page.url or ""
if "/search" in url or "search" in url.lower():
if await page.locator(RESULT_CONTAINER_SELECTOR).count() > 0:
return True
except Exception:
pass
await interruptible_sleep(0.8)
return False
return False
return False
result = await poll_until(_ready, timeout_sec=timeout_sec, interval_sec=0.8)
return result.ok
async def _has_end_marker(page) -> bool:

View File

@@ -74,7 +74,6 @@ async def run_keyword_search_task(
真实 skill 中还应:
- 写 SQLite 入库(items 表)
- 写 scrape_tasks / task_logs 任务日志
- 使用 RpaVideoSession 录制步骤留痕
- 失败时 capture_failure 截图
"""

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,152 @@
# Real Windows Desktop RPA Example
这是从已跑通业务技能 `D:\OpenClaw\client-marketing\add-friend-wecom` 提炼的 Windows UIA 参考案例。它用于教技术人员和 AI 编程工具如何从真实拾取结果得到稳定定位器,并完成窗口、控件、动作、等待和诊断闭环。
## 状态
- **技术路线已真实验证**:Windows UIA + Python `uiautomation`
- **来源业务已真实跑通**:企业微信添加客户(通讯录 → 新的客户 → 添加 → 搜索 → 发送)
- **本目录默认安全模式**:只提供 probe、dry-run、assisted 范式,不默认发送真实邀请
- **不是完整 skill 仓库**:按 Copy map 选择性复制
## 先读
1. [`../../development/RPA_ELEMENT_LOCATING.md`](../../development/RPA_ELEMENT_LOCATING.md)
2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子)
3. 本 README 的“原始拾取到生产定位器”
## 成功案例验证了什么
```text
枚举目标顶层窗口
→ 激活并最大化主窗(勿无条件 SW_RESTORE)
→ 从 HWND 获取 UIA root / 业务子 pane
→ 在父容器中定位(Name 与 Value 分开看)
→ 真实点击并校验后置状态
→ 等待子窗口或业务结果
→ 处理独立/嵌套弹窗
→ probe-ui 诊断
```
来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。
本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。
## 目录结构
```text
real_desktop_rpa/
README.md
assets/
element-map.sample.yaml
selector-capture.sample.xml
scripts/
service/
desktop_session.py
uia_locator.py
wecom_reference_flow.py
util/
selectors.py
tests/
test_selector_contract.py
```
## 原始拾取到生产定位器
### 搜索框(添加客户窗)
```text
原始:Window(SearchExternalsWnd) → 多层 Layout/index → search_layout → searchedit
提炼:窗口 → search_layout → Name=searchedit
```
### 新的客户(易错)
```text
原始:通讯录 pane → wlist_view → item[2] → status_layout → main_chattext
提炼:wlist_view 内找 子控件 main_chattext.Value=新的客户 的那一行,再点该行 status_layout
禁止:全树第一个 Name=status_layout(常为「群聊」)
禁止:把选择器标题「新的客户」当成 UIA Name 去搜
```
### 添加按钮
```text
提炼:search_external → wbtn_container(子文案 Value=添加)
禁止:搜索旁 plus_button 下的同名 wbtn_container
```
正确提炼方式:
1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。
2. 保留最小业务父容器。
3. 叶子用稳定 Name **和/或** Value / 子控件 Value。
4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。
5. 验证父容器内候选数量为 `1`。
6. 重启应用后再次验证。
不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。
## Element map
本目录的 [`assets/element-map.sample.yaml`](assets/element-map.sample.yaml) 只展示来源案例的元素登记方法。复制后必须重新拾取目标软件并全部替换。
关键规则:
- 窗口不唯一时失败,不取 `hwnds[0]`。
- 控件不唯一时失败,不取第一个匹配。
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。
- 行顺序不是身份;用业务文案(Value)选择结果行。
- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。
- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。
## 三种实施模式
| 模式 | 行为 | 用途 |
|---|---|---|
| `probe` | 只检查依赖、窗口和关键控件 | 安装、自检、版本变更排障 |
| `dry-run` | 导航并填写,提交前停止 | 开发和验收默认 |
| `assisted` | 填写后等待用户人工确认 | 不可逆动作或现场联调 |
业务技能若增加 `auto`,必须实现明确确认策略、频率限制、业务后置状态和 `RESULT_UNCONFIRMED`。
## Copy map
| 来源 | 目标 | 说明 |
|---|---|---|
| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活/最大化 |
| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位(含 Value)、点击、输入 |
| `scripts/util/selectors.py` | `scripts/util/<platform>_selectors.py` | element spec;必须全部替换 |
| `scripts/service/wecom_reference_flow.py` | `scripts/service/<business>_flow.py` | 只复制流程分层,不复制企微 selector |
| `assets/element-map.sample.yaml` | `assets/<platform>-element-map.yaml` | 真实拾取后填写 |
| `tests/test_selector_contract.py` | `tests/test_selector_contract.py` | 定位器契约测试思路 |
## 必须补齐的业务能力
- 目标应用安装/版本 doctor
- 真实业务元素清单
- 失败截图、UIA树和顶层窗口快照
- Activity/RpaVideoSession 步骤
- 不可逆动作确认
- 业务成功/失败/结果不明确状态
- 频率限制、幂等和断点续跑
- 真实集成测试(默认不进 CI)
## 禁止照抄
- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd`
- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext`
- 企业微信列表第 0/1/2 行含义或固定 index
- 来源案例的本机路径、账号和业务数据
- 任何未经目标软件真实拾取验证的 fallback
## 验收
- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点)
- 每个关键元素在目标作用域内唯一
- 关闭重开软件后仍能定位
- 多窗口时不会误选
- UI变化时停止并保存截图/UIA树
- dry-run 不执行不可逆提交
- assisted 清楚提示用户确认
- 提交后仅在业务后置状态成立时报告成功

View File

@@ -0,0 +1,117 @@
source_case: add-friend-wecom
status: verified_source_redacted_example
surface: windows
driver: uiautomation
notes:
- 选择器显示名 ≠ UIA Name;可见中文常在 Value / 子 Label
- 禁止全树第一个 status_layout / wbtn_container
- 主窗流程先 maximize;activate 勿无条件 SW_RESTORE
elements:
- logical_name: main_window
purpose: 企业微信主窗口
scope:
process_name: WXWork
primary_locator:
window_class: WeWorkWindow
title: 企业微信
expected_count: 1
postcondition: UIA root 可读取;业务需要时已最大化
replace_in_new_skill: true
- logical_name: contacts_entry
purpose: 打开通讯录
scope:
logical_parent: main_window
primary_locator:
name: org_op_new
control_type: TreeItem
value_hint: 通讯录
expected_count: 1
postcondition: 出现企业微信-通讯录业务 pane
replace_in_new_skill: true
- logical_name: contacts_pane
purpose: 通讯录业务子层作用域
scope:
logical_parent: main_window
primary_locator:
name_contains: 通讯录
expected_count: 1
postcondition: 可枚举 wlist_view
replace_in_new_skill: true
- logical_name: new_customer_row
purpose: 侧栏进入「新的客户」
scope:
logical_parent: contacts_pane
ancestor_name: wlist_view
primary_locator:
row_leaf_name: status_layout
child_name: main_chattext
child_value: 新的客户
expected_count: 1
postcondition: panel_title_label Value=新的客户 或出现 search_external
failure_note: 禁止点全树第一个 status_layout(常为群聊)
replace_in_new_skill: true
- logical_name: panel_title
purpose: 校验已进入新的客户页
scope:
logical_parent: contacts_pane
primary_locator:
name: panel_title_label
value: 新的客户
expected_count: 1
replace_in_new_skill: true
- logical_name: add_customer_button
purpose: 打开添加客户窗口
scope:
logical_parent: contacts_pane
ancestor_name: search_external
primary_locator:
name: wbtn_container
child_name: ww_btn_text
child_value: 添加
expected_count: 1
postcondition: 出现 SearchExternalsWnd
failure_note: 禁止点搜索旁 plus_button 下的 wbtn_container
replace_in_new_skill: true
- logical_name: add_customer_window
purpose: 添加客户窗口
scope:
process_name: WXWork
primary_locator:
window_class: SearchExternalsWnd
title: 添加客户
expected_count: 1
postcondition: search_input 可定位
replace_in_new_skill: true
- logical_name: search_input
purpose: 输入待搜索账号
scope:
logical_parent: add_customer_window
ancestor_name: search_layout
primary_locator:
name: searchedit
expected_count: 1
ready_condition:
visible: true
enabled: true
postcondition: 结果列表或用户不存在弹窗出现
replace_in_new_skill: true
- logical_name: verify_message_input
purpose: 填写添加验证语
scope:
window_class: InputReasonWnd
title_any:
- 输入认证信息
- 发送添加邀请
primary_locator:
name: name_edit
expected_count: 1
postcondition: 输入值可读取或发送按钮可用
replace_in_new_skill: true

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
脱敏教学摘录:展示“拾取器长路径”而非可直接复制的生产 selector。
生产代码应保留稳定窗口、业务父容器和叶子属性,删除无业务含义的深层 index。
-->
<selector name="search_input" source="inspector-export">
<window process="WXWork" class="SearchExternalsWnd" title="添加客户" />
<uia name="content_container" index="1" />
<uia name="search_layout" index="0" />
<uia name="inner_search_layout" index="1" />
<uia name="searchedit" index="1" />
</selector>
<!--
新的客户:长路径示意 → 生产应改为
wlist_view 内 child main_chattext.Value=新的客户 的行,再点该行 status_layout。
禁止全树第一个 status_layout。
-->
<selector name="new_customer_row" source="inspector-export">
<window process="WXWork" class="WeWorkWindow" title="企业微信" />
<pane name="企业微信-通讯录" />
<uia name="wlist_view" />
<uia name="item" index="2" />
<uia name="status_layout" />
<uia name="main_chattext" value="新的客户" />
</selector>

View File

@@ -0,0 +1,129 @@
"""Windows 顶层窗口作用域:枚举、唯一性校验和激活。"""
from __future__ import annotations
import ctypes
import ntpath
import time
from ctypes import wintypes
from dataclasses import dataclass
from typing import Iterable
from util.selectors import WindowSpec
class DesktopRpaError(RuntimeError):
def __init__(self, code: str, message: str) -> None:
super().__init__(f"ERROR:{code} {message}")
self.code = code
self.message = message
@dataclass(frozen=True)
class WindowCandidate:
hwnd: int
pid: int
process_name: str
class_name: str
title: str
visible: bool
user32 = ctypes.windll.user32
kernel32 = ctypes.windll.kernel32
SW_RESTORE = 9
SW_MAXIMIZE = 3
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
def _process_name(pid: int) -> str:
handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, pid)
if not handle:
return ""
try:
size = wintypes.DWORD(32768)
buffer = ctypes.create_unicode_buffer(size.value)
if not kernel32.QueryFullProcessImageNameW(
handle,
0,
buffer,
ctypes.byref(size),
):
return ""
return ntpath.splitext(ntpath.basename(buffer.value))[0]
finally:
kernel32.CloseHandle(handle)
def enumerate_top_windows() -> list[WindowCandidate]:
found: list[WindowCandidate] = []
@ctypes.WINFUNCTYPE(ctypes.c_bool, wintypes.HWND, wintypes.LPARAM)
def callback(hwnd, _lparam):
pid = wintypes.DWORD()
user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
cls = ctypes.create_unicode_buffer(256)
user32.GetClassNameW(hwnd, cls, 256)
length = user32.GetWindowTextLengthW(hwnd)
title = ctypes.create_unicode_buffer(length + 1)
user32.GetWindowTextW(hwnd, title, length + 1)
found.append(
WindowCandidate(
hwnd=int(hwnd),
pid=int(pid.value),
process_name=_process_name(int(pid.value)),
class_name=cls.value,
title=title.value,
visible=bool(user32.IsWindowVisible(hwnd)),
)
)
return True
user32.EnumWindows(callback, 0)
return found
def resolve_unique_window(
spec: WindowSpec,
*,
candidates: Iterable[WindowCandidate] | None = None,
) -> WindowCandidate:
pool = list(candidates) if candidates is not None else enumerate_top_windows()
matched = [
item
for item in pool
if item.visible
and item.process_name.casefold() == spec.process_name.casefold()
and item.class_name == spec.class_name
and (spec.title is None or item.title == spec.title)
]
if not matched:
raise DesktopRpaError("WINDOW_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != 1:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 匹配 {len(matched)} 个窗口,请按 PID/账号补充作用域",
)
return matched[0]
def activate_window(window: WindowCandidate, *, maximize: bool = False) -> None:
"""激活窗口。
maximize=True:主窗进入依赖完整侧栏的流程时使用。
maximize=False:只前置;**禁止**对已最大化窗口无条件 SW_RESTORE。
"""
hwnd = window.hwnd
iconic = bool(user32.IsIconic(hwnd))
zoomed = bool(user32.IsZoomed(hwnd))
if maximize:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not zoomed:
user32.ShowWindow(hwnd, SW_MAXIMIZE)
else:
if iconic:
user32.ShowWindow(hwnd, SW_RESTORE)
if not user32.SetForegroundWindow(hwnd):
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={hwnd}")
time.sleep(0.35 if maximize else 0.2)

View File

@@ -0,0 +1,127 @@
"""作用域内 UIA 唯一定位、真实点击和逐字输入。"""
from __future__ import annotations
import random
from typing import Any, Iterator
from service.desktop_session import DesktopRpaError, WindowCandidate, activate_window
from util.selectors import ElementSpec
def require_uiautomation():
try:
import uiautomation as auto
except ImportError as exc:
raise DesktopRpaError("UIA_UNAVAILABLE", "缺少 uiautomation 依赖") from exc
return auto
def root_from_window(window: WindowCandidate):
auto = require_uiautomation()
return auto.ControlFromHandle(window.hwnd)
def control_value(control: Any) -> str:
try:
auto = require_uiautomation()
raw = control.GetPropertyValue(auto.PropertyId.ValueValueProperty)
return str(raw or "").strip()
except Exception:
return ""
def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
if depth > max_depth:
return
yield root
if depth == max_depth:
return
try:
children = root.GetChildren()
except Exception:
children = []
for child in children:
yield from _walk(child, depth=depth + 1, max_depth=max_depth)
def _child_has_value(control: Any, child_name: str, expected_value: str, *, max_depth: int = 10) -> bool:
for node in _walk(control, max_depth=max_depth):
if (getattr(node, "Name", None) or "") != child_name:
continue
if control_value(node) == expected_value:
return True
return False
def _matches(control: Any, spec: ElementSpec) -> bool:
if spec.name is not None and (getattr(control, "Name", None) or "") != spec.name:
return False
if spec.value is not None and not spec.child_value_name:
if control_value(control) != spec.value:
return False
if spec.automation_id is not None and (
getattr(control, "AutomationId", None) or ""
) != spec.automation_id:
return False
if spec.control_type is not None and (
(getattr(control, "ControlTypeName", None) or "").removesuffix("Control")
!= spec.control_type.removesuffix("Control")
):
return False
if spec.class_name is not None and (
getattr(control, "ClassName", None) or ""
) != spec.class_name:
return False
if spec.child_value_name and spec.value:
if not _child_has_value(control, spec.child_value_name, spec.value):
return False
return True
def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
search_root = root
if spec.ancestor_name:
ancestor_spec = ElementSpec(
logical_name=f"{spec.logical_name}.ancestor",
name=spec.ancestor_name,
)
search_root = locate_unique(root, ancestor_spec, max_depth=max_depth)
matched = [
control for control in _walk(search_root, max_depth=max_depth) if _matches(control, spec)
]
if not matched:
raise DesktopRpaError("ELEMENT_NOT_FOUND", f"{spec.logical_name} 未找到")
if len(matched) != spec.expected_count:
raise DesktopRpaError(
"ELEMENT_AMBIGUOUS",
f"{spec.logical_name} 期望 {spec.expected_count} 个,实际 {len(matched)} 个",
)
return matched[0]
def click_control(
control: Any,
*,
window: WindowCandidate,
maximize: bool = False,
) -> None:
activate_window(window, maximize=maximize)
rect = control.BoundingRectangle
if rect.width() <= 0 or rect.height() <= 0 or rect.left < -10000:
raise DesktopRpaError("ELEMENT_NOT_READY", "目标元素不可见或边界矩形无效")
control.Click(
x=random.randint(-2, 2),
y=random.randint(-2, 2),
simulateMove=True,
)
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
auto = require_uiautomation()
click_control(control, window=window, maximize=False)
auto.SendKeys("{Ctrl}a", waitTime=0.05)
auto.SendKeys("{Delete}", waitTime=0.05)
for char in text:
auto.SendKeys(char, waitTime=random.uniform(0.04, 0.12))

View File

@@ -0,0 +1,58 @@
"""企业微信成功案例的安全提炼:probe 和 dry-run,不默认发送邀请。"""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from service.desktop_session import activate_window, resolve_unique_window
from service.uia_locator import locate_unique, root_from_window, type_text
from util.selectors import (
ADD_CUSTOMER_WINDOW,
CONTACTS_ENTRY,
MAIN_WINDOW,
NEW_CUSTOMER_ROW,
SEARCH_INPUT,
)
def probe() -> dict[str, Any]:
"""最小 probe:主窗 + 通讯录入口。
生产技能应对「新的客户 / 添加」等关键节点做更深探测;本函数不等于生产完整度。
调用前会 maximize 主窗,避免侧栏折叠导致 org_op_new 抖动。
"""
main = resolve_unique_window(MAIN_WINDOW)
activate_window(main, maximize=True)
root = root_from_window(main)
contacts = locate_unique(root, CONTACTS_ENTRY)
payload: dict[str, Any] = {
"success": True,
"minimized_probe": True,
"main_window": asdict(main),
"contacts_entry": {
"name": getattr(contacts, "Name", ""),
"control_type": getattr(contacts, "ControlTypeName", ""),
},
}
# 可选:若已在通讯录页,尝试按 Value 定位「新的客户」行(失败不视为 probe 失败)
try:
row = locate_unique(root, NEW_CUSTOMER_ROW)
payload["new_customer_row"] = {"name": getattr(row, "Name", "")}
except Exception:
payload["new_customer_row"] = None
return payload
def fill_search_dry_run(target: str) -> dict[str, Any]:
"""要求操作者已打开“添加客户”窗口;只填写搜索框,不提交。"""
search_window = resolve_unique_window(ADD_CUSTOMER_WINDOW)
activate_window(search_window, maximize=False)
root = root_from_window(search_window)
search_input = locate_unique(root, SEARCH_INPUT)
type_text(search_input, target, window=search_window)
return {
"success": True,
"mode": "dry-run",
"message": "已填写搜索条件,未发送邀请",
}

View File

@@ -0,0 +1,63 @@
"""脱敏 UIA element specs;复制到新技能后必须用真实拾取结果替换。"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class WindowSpec:
logical_name: str
process_name: str
class_name: str
title: Optional[str] = None
@dataclass(frozen=True)
class ElementSpec:
logical_name: str
name: Optional[str] = None
"""UIA Name(内部 id,未必是界面中文)。"""
value: Optional[str] = None
"""UIA Value(可见中文文案常在此)。"""
automation_id: Optional[str] = None
control_type: Optional[str] = None
class_name: Optional[str] = None
ancestor_name: Optional[str] = None
"""先定位到该 Name 的祖先,再在其下搜索。"""
child_value_name: Optional[str] = None
"""在候选控件子树中查找该 Name 的子控件,并用 value 匹配其 Value。"""
expected_count: int = 1
MAIN_WINDOW = WindowSpec("main_window", "WXWork", "WeWorkWindow", "企业微信")
ADD_CUSTOMER_WINDOW = WindowSpec(
"add_customer_window",
"WXWork",
"SearchExternalsWnd",
"添加客户",
)
CONTACTS_ENTRY = ElementSpec(
"contacts_entry",
name="org_op_new",
control_type="TreeItem",
)
# 示意:用子控件 main_chattext 的 Value 定位「新的客户」行(禁止全树第一个 status_layout)
NEW_CUSTOMER_ROW = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
ADD_BUTTON = ElementSpec(
"add_customer_button",
name="wbtn_container",
ancestor_name="search_external",
)
SEARCH_INPUT = ElementSpec(
"search_input",
name="searchedit",
ancestor_name="search_layout",
)

View File

@@ -0,0 +1,99 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock, patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from service.desktop_session import ( # noqa: E402
DesktopRpaError,
WindowCandidate,
activate_window,
resolve_unique_window,
)
from service import uia_locator # noqa: E402
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
class SelectorContractTest(unittest.TestCase):
def test_unique_window_is_required(self) -> None:
candidate = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True)
self.assertEqual(
resolve_unique_window(MAIN_WINDOW, candidates=[candidate]),
candidate,
)
def test_duplicate_windows_fail_instead_of_taking_first(self) -> None:
candidates = [
WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", True),
WindowCandidate(2, 11, "WXWork", "WeWorkWindow", "企业微信", True),
]
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=candidates)
self.assertEqual(ctx.exception.code, "ELEMENT_AMBIGUOUS")
def test_hidden_window_is_not_a_candidate(self) -> None:
hidden = WindowCandidate(1, 10, "WXWork", "WeWorkWindow", "企业微信", False)
with self.assertRaises(DesktopRpaError) as ctx:
resolve_unique_window(MAIN_WINDOW, candidates=[hidden])
self.assertEqual(ctx.exception.code, "WINDOW_NOT_FOUND")
def test_activate_without_maximize_skips_restore_when_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = True
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=False)
user32.ShowWindow.assert_not_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_activate_maximize_when_not_zoomed(self) -> None:
window = WindowCandidate(42, 10, "WXWork", "WeWorkWindow", "企业微信", True)
with patch("service.desktop_session.user32") as user32:
user32.IsIconic.return_value = False
user32.IsZoomed.return_value = False
user32.SetForegroundWindow.return_value = True
activate_window(window, maximize=True)
user32.ShowWindow.assert_called()
user32.SetForegroundWindow.assert_called_once_with(42)
def test_locate_unique_matches_child_value(self) -> None:
row = MagicMock()
row.Name = "status_layout"
row.AutomationId = ""
row.ControlTypeName = "TreeControl"
row.ClassName = ""
label = MagicMock()
label.Name = "main_chattext"
label.GetChildren.return_value = []
row.GetChildren.return_value = [label]
root = MagicMock()
root.Name = "wlist_view"
root.AutomationId = ""
root.ControlTypeName = "TreeControl"
root.ClassName = ""
root.GetChildren.return_value = [row]
with patch.object(uia_locator, "control_value", side_effect=lambda c: "新的客户" if c is label else ""):
spec = ElementSpec(
"new_customer_row",
name="status_layout",
ancestor_name="wlist_view",
child_value_name="main_chattext",
value="新的客户",
)
# ancestor is root itself when searching from root with name wlist_view
found = uia_locator.locate_unique(root, ElementSpec("wlist", name="wlist_view"))
self.assertIs(found, root)
found_row = uia_locator.locate_unique(root, spec)
self.assertIs(found_row, row)
if __name__ == "__main__":
unittest.main()

View File

@@ -18,6 +18,7 @@
- **真实第三方网站 + 高风控 + 人工验证码** → 优先看 [`../real_browser_rpa/README.md`](../real_browser_rpa/README.md)
- 目标系统有稳定 **官方 API** → 不要硬写浏览器 RPA
- **不要把本示例的业务字段、selector、sandbox URL 原样复制**到你的 skill
- 复制前先读 `development/RPA_ELEMENT_LOCATING.md` 与 `development/RPA_BROWSER.md`;即使 DOM 可控也要定义作用域、唯一性和后置状态
## 目录结构

View File

@@ -159,6 +159,8 @@ def _normalize_error_code(raw: str) -> str:
def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if payload.get("success") is False:
err = payload.get("error") if isinstance(payload.get("error"), dict) else {}
code = _normalize_error_code(str(err.get("code") or ""))
@@ -174,10 +176,10 @@ def _validate_pick_payload(payload: dict[str, Any]) -> dict[str, Any]:
raise AccountManagerError("NO_ACCOUNT", message or "没有可用账号。")
raise AccountManagerError(code or "ENSURE_WEB_FAILED", message or "ensure-web 失败。")
if not isinstance(payload, dict):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回格式异常。")
if not payload.get("profile_dir"):
raise AccountManagerError("ACCOUNT_NOT_FOUND", "ensure-web 返回的账号缺少 profile_dir。")
if not payload.get("lease_token"):
raise AccountManagerError("LEASE_REQUIRED", "account-manager 未返回 lease_token,禁止打开浏览器。")
return payload
@@ -226,7 +228,44 @@ def _ensure_web_with_lease(
return _validate_pick_payload(payload)
def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
def _pick_existing_with_lease(
platform: str,
*,
login_id: Optional[str] = None,
label: Optional[str] = None,
) -> Dict[str, Any]:
argv = [
"account",
"pick-web",
"--platform",
platform,
"--lease",
"--holder",
LEASE_HOLDER,
"--ttl-sec",
LEASE_TTL_SEC,
"--purpose",
"rpa",
]
if login_id:
argv.extend(["--login-id", login_id])
elif label:
argv.extend(["--label", label])
else:
raise AccountManagerError("AMBIGUOUS_ACCOUNT", "指定账号缺少可用于带租约选择的登录标识或名称。")
proc = _run_argv(argv)
out = proc.stdout or ""
if proc.returncode != 0 and not out.strip():
raise AccountManagerError(
"PICK_WEB_FAILED",
(proc.stderr or "").strip() or "pick-web 子进程失败",
)
payload = _parse_last_json(out)
return _validate_pick_payload(payload)
def _pick_by_id_with_lease(platform: str, account_id: int) -> Dict[str, Any]:
proc = _run_argv(["account", "get", str(account_id)])
out = proc.stdout or ""
if proc.returncode != 0 or out.startswith("ERROR:"):
@@ -239,6 +278,8 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
else:
raise AccountManagerError("ACCOUNT_NOT_FOUND", "account get 返回非对象 JSON。")
if data.get("success") is False:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"指定账号 {account_id} 不存在或获取失败。")
platform_key = str(data.get("platform_key") or "").lower()
if platform_key != platform.lower():
raise AccountManagerError(
@@ -248,9 +289,26 @@ def _pick_by_id(platform: str, account_id: int) -> Dict[str, Any]:
profile_dir = str(data.get("profile_dir") or "").strip()
if not profile_dir:
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 缺少 profile_dir。")
data["profile_dir"] = profile_dir
data["lease_token"] = ""
return data
if str(data.get("status") or "active").lower() != "active":
raise AccountManagerError("ACCOUNT_NOT_FOUND", f"账号 {account_id} 当前不是 active 状态。")
try:
selected = _pick_existing_with_lease(
platform,
login_id=(str(data.get("login_id") or "").strip() or None),
label=(str(data.get("account_label") or "").strip() or None),
)
except AccountManagerError as exc:
if exc.code in ("NO_ACCOUNT", "ACCOUNT_NOT_FOUND"):
raise AccountManagerError("LEASE_CONFLICT", LEASE_BUSY_MESSAGE) from exc
raise
if str(selected.get("id")) != str(account_id):
release_lease(str(selected.get("lease_token") or ""))
raise AccountManagerError(
"AMBIGUOUS_ACCOUNT",
f"无法唯一取得指定账号 {account_id} 的租约,请检查重复的登录标识或账号名称。",
)
return selected
def pick_web_account(
@@ -271,7 +329,7 @@ def pick_web_account(
login_id = aid_raw
aid_raw = ""
else:
return _pick_by_id(platform_key, int(aid_raw))
return _pick_by_id_with_lease(platform_key, int(aid_raw))
lid = (login_id or "").strip() or None
if lid and _is_placeholder_config(lid):

View File

@@ -51,7 +51,7 @@ async def run_batch_submit(
"""
最小编排示例。
真实 skill 中还应:写 task_logs、entitlement、RpaVideoSession 等。
真实 skill 中还应:写业务表、entitlement、RpaVideoSession 等。
账号 pick / lease 在 adapter 内完成,编排层不直接碰 account-manager。
"""
del force # 示例占位;真实 skill 可用于跳过确认

View File

@@ -0,0 +1,63 @@
from __future__ import annotations
import json
import subprocess
import unittest
from unittest.mock import patch
from service.account_client import AccountManagerError, pick_web_account
def _completed(payload: dict) -> subprocess.CompletedProcess[str]:
return subprocess.CompletedProcess(
args=["python", "account-manager"],
returncode=0,
stdout=json.dumps(payload, ensure_ascii=False),
stderr="",
)
class TestExplicitAccountLease(unittest.TestCase):
@patch("service.account_client._run_argv")
def test_explicit_account_id_is_reselected_with_lease(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
leased = {**account, "lease_token": "lease-42"}
run_argv.side_effect = [_completed(account), _completed(leased)]
result = pick_web_account("target-platform", account_id="42")
self.assertEqual(result["id"], 42)
self.assertEqual(result["lease_token"], "lease-42")
self.assertEqual(run_argv.call_args_list[0].args[0], ["account", "get", "42"])
pick_args = run_argv.call_args_list[1].args[0]
self.assertIn("pick-web", pick_args)
self.assertIn("--lease", pick_args)
self.assertIn("user@example.com", pick_args)
@patch("service.account_client._run_argv")
def test_missing_lease_token_is_rejected(self, run_argv) -> None:
account = {
"id": 42,
"platform_key": "target-platform",
"account_label": "主账号",
"login_id": "user@example.com",
"profile_dir": "D:/data/profile-42",
"status": "active",
}
run_argv.side_effect = [_completed(account), _completed(account)]
with self.assertRaises(AccountManagerError) as raised:
pick_web_account("target-platform", account_id="42")
self.assertEqual(raised.exception.code, "LEASE_REQUIRED")
if __name__ == "__main__":
unittest.main()

View File

@@ -2,6 +2,8 @@
本文件说明 `assets/actions.json` 与 `scripts/main.py` CLI 的对应关系,供 **Agent 编排**与宿主 **Skill Action Runtime** 使用。这是 Agent 侧关于 Action / 任务中心的**主契约**;实现细节与开发规范见 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
**先读产品入口:** 对话 / 数据管理 / 定时任务在宿主上**平级**,何时挂哪个入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)(本文只讲字段与执行模型)。
**边界:** 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);CLI 参数见 [`CLI.md`](CLI.md)。
## Agent 何时用 Action(与 CLI 的分工)
@@ -27,6 +29,7 @@
3. 有可感知副作用时:提供 `confirmation.message`
4. 需要用户/Agent 入参时:简单 `inputSchema` + entrypoint 中的 `{{fieldName}}`
5. `SKILL.md`「用户意图路由表」与 `CLI.md`「Action 强制黑名单」**出现同一 action id / 命令**
6. 若处理的是**业务队列表中的一行**:遵守 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)——默认只捞**可操作**角色;开始抢占为**进行中**;结束回写成功/失败(失败带原因);确认文案写清仅可操作态可处理
默认模板 `assets/actions.json` 仅含诊断类 sync Action,**不要**为通过本 checklist 而塞假业务 Action;业务技能按需添加。完整字段见下文 Phase 1。
@@ -48,10 +51,10 @@ JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这
## Action 是否必需
Action 是**可选能力**:
Action 是否提供、挂哪些 `placements`,由本技能 **入口定位表**决定(见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md)),不是「可有可无的附属能力」:
- 没有宿主直接操作需求的技能,**可以不提供** `actions.json`。
- 需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中的技能,**必须**提供 `assets/actions.json`。
- 定位需要出现在**数据管理按钮**、**技能详情**、**定时任务**或 **Agent 直接调用**中时,**必须**提供 `assets/actions.json` 并声明对应入口。
- 定位确认无任何宿主结构化入口时,**可以不提供** `actions.json`。
- **含浏览器 RPA / 长耗时任务**的技能:**必须**提供至少一条 `executionProfile: "async"` 且 `placements` 含 `agent` 的 action(见 `POLICY-SKILL-ACTION-001`)。
## 文件位置
@@ -72,34 +75,50 @@ Action 是**可选能力**:
|------|------|------|
| `schemaVersion` | 是 | 当前固定为 `1` |
| `skill` | 是 | 必须与 `SKILL.md` 的 `metadata.openclaw.slug` 一致 |
| `actions` | 是 | Action 数组,每项 `id` 唯一 |
| `actions` | 是 | Action 数组,每项 `id` 唯一;**数组相对顺序**决定数据管理 toolbar / row / batch 技能按钮的展示顺序(见下节) |
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。可选 Data Policy 见 [`POLICY.md`](POLICY.md) 与 [`assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json)。可选单元格呈现见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) 与 [`assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
## 五职责正交(必读)
## 六职责正交(必读)
| 字段 / 层 | 只决定什么 | 不决定什么 |
|-----------|------------|------------|
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async |
| **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail;`row`=行级入口,壳由宿主呈现) | 不决定 sync/async,不决定点了是否弹窗,不决定行级用操作列还是右键,不决定谁能点 |
| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除 / **导入**」 | 不强制;无配置时宿主仍直写 SQLite;**import 时 UI 仍用宿主「导入数据」面板** |
| **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口 |
| **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 |
| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** |
| **`hoverHint`** | **可选**:数据管理**工具栏悬停**文案 | 不决定短按钮名;不决定点下去弹不弹窗;**不回退** `description` / `confirmation.message`;省略 = 不显示悬停。当前宿主实现为顶栏技能按钮 |
| **`inputSchema.visibleWhen`** | **可选**:Action **参数表单**字段条件显隐(先选模式再出对应参数) | **不是** Data Policy L3 行态 `visibleWhen`;不决定 placements;旧宿主忽略则字段平铺全显 |
| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 |
| **`cellPresentation`** | **可选**:某表某行某列的语义标色/强调(tone + 说明) | 不替代业务列;不下发 CSS;用途不限(超时、预警、驳回等);见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) |
| **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 |
| **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 |
| **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 |
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。
一个业务能力通常只定义**一个** Action,用 `placements` 同时挂到多个入口。不要拆成 `sync-records-toolbar` / `sync-records-cron` 等副本,除非语义确实不同;即便拆分,也必须调用同一个领域业务内核。需要按用户/角色藏按钮或滤行时,**不要**按角色复制 Action,应启用顶层 `policy` 并由技能算出 Snapshot(见 [`POLICY.md`](POLICY.md))。
## Phase 1 当前稳定支持(新技能应只使用这些)
| 层级 | 稳定字段 / 能力 |
|------|----------------|
| 顶层 | `schemaVersion`、`skill`、`actions` |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`bind`** |
| 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块);可选 **`cellPresentation`**(单元格呈现启用块) |
| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `hoverHint`、`inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) |
| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron` |
| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 |
| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` \| `"import"` — 接管默认新增/编辑/删除/导入(须 `bind.tables`) |
| **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` |
| confirmation | `{ "message": "..." }` |
| **policy** | 可选;`enabled` + `entrypoint`(推荐 `policy` CLI);见 POLICY.md。未启用 = L0 |
| **cellPresentation** | 可选;`{ "enabled": true }` 时宿主应加载单元格呈现;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。未启用 = P0 |
| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly`;可选 `display_type` / `format`(`textarea` / `date` / `datetime_unix_seconds` / `url` / `email` / `lookup`);可选 **`visibleWhen`**(字段条件显隐,见专节) |
| confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 |
| **hoverHint** | 可选字符串。数据管理**工具栏悬停**文案;与 `description` / `confirmation.message` **独立**。省略则不显示悬停,不回退。短名已够懂时不要写。需宿主已支持该字段;当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出 |
| **visibleWhen** | 可选。写在 **property** 上(非 action 顶层):依赖同表单其它字段值决定是否展示;隐藏字段不提交、不校验 `required`。**勿与** [`POLICY.md`](POLICY.md) L3 行态 `visibleWhen` 混淆 |
| **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) |
| **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) |
| **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) |
| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) |
| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) |
## `bind.tables`(表级绑定)
@@ -122,6 +141,396 @@ Action 是**可选能力**:
- **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。
- `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。
## `replaces`(可选:接管默认新增 / 编辑 / 删除 / 导入)
有领域副作用的表(归一化字段、建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明 `replaces`,**用户仍点宿主默认按钮**,由技能 CLI 完成写入。
### 怎么选
| 场景 | 建议 |
|------|------|
| 纯表字段、无副作用 | 不配 `replaces`,宿主直写即可 |
| 单条新增/编辑/删除有副作用 | `replaces: create` / `update` / `delete` |
| 批量导入也有副作用(推荐) | 另配 `replaces: import`(与 create 可同表并存) |
| 只有 create、暂无 import | 宿主「导入」面板确认后会**逐行调 create**(兼容);正式产品应补 `import` |
### 导入分流(必读)
点工具栏「导入」时:
1. **UI 一律**打开宿主「导入数据」面板(下载模板 → 选文件 → 映射 → 出错规则 → 预览)。
2. **写库**按表能力分流:
- 有 `replaces: import` → 把选中文件路径 + 出错规则交给该 Action(技能解析并写入)
- 仅有 `replaces: create` → 宿主按行调用 create(兼容)
- 都没有 → 宿主直写 SQLite
**不要**再做一张技能自己的「开始执行」导入表单来冒充导入体验。`placements` 对 import 保持 `[]`;`description` 写明「由宿主导入面板调用」。
可抄文件见 [`assets/examples/`](../assets/examples/):`replaces-*.action.example.json`。
### 示例:create
```json
{
"id": "item-create",
"label": "新增记录",
"description": "新增业务记录并执行领域副作用(走技能 CLI,非宿主直写库)。",
"replaces": "create",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": { "tables": ["items"] },
"hostEffects": { "onSuccess": ["reloadTable"] },
"inputSchema": {
"type": "object",
"required": ["name"],
"properties": {
"name": { "type": "string", "title": "名称" },
"note": { "type": "string", "title": "备注" }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["create", "--name", "{{name}}", "--note", "{{note}}"]
}
}
```
### 示例:update
```json
{
"id": "item-update",
"label": "编辑记录",
"description": "编辑业务记录并重算派生字段。",
"replaces": "update",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["items"],
"inputMapping": {
"id": "$row.$pk",
"name": "$row.name",
"note": "$row.note"
}
},
"hostEffects": { "onSuccess": ["reloadTable"] },
"inputSchema": {
"type": "object",
"required": ["id", "name"],
"properties": {
"id": { "type": "number", "title": "编号", "readOnly": true },
"name": { "type": "string", "title": "名称" },
"note": { "type": "string", "title": "备注" }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["update", "--id", "{{id}}", "--name", "{{name}}", "--note", "{{note}}"]
}
}
```
### 示例:delete
```json
{
"id": "item-delete",
"label": "删除记录",
"description": "删除业务记录并清理关联副作用。",
"replaces": "delete",
"placements": [],
"executionProfile": "sync",
"interaction": "confirm",
"confirmation": { "message": "确定删除该记录?关联数据将一并清理。" },
"bind": {
"tables": ["items"],
"inputMapping": { "id": "$row.$pk" }
},
"hostEffects": { "onSuccess": ["reloadTable", "clearSelection"] },
"inputSchema": {
"type": "object",
"required": ["id"],
"properties": {
"id": { "type": "number", "title": "编号", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["delete", "--id", "{{id}}"]
}
}
```
### 示例:import
```json
{
"id": "item-import",
"label": "导入记录",
"description": "由宿主「导入数据」面板调用:解析 Excel/CSV 并写入业务表(含领域校验与派生字段)。",
"replaces": "import",
"placements": [],
"executionProfile": "sync",
"interaction": "form",
"bind": { "tables": ["items"] },
"hostEffects": { "onSuccess": ["reloadTable"] },
"inputSchema": {
"type": "object",
"required": ["filePath"],
"properties": {
"filePath": {
"type": "string",
"title": "导入文件",
"description": "由宿主导入面板传入的 Excel/CSV 路径。",
"format": "file"
},
"onError": {
"type": "string",
"title": "导入规则(出错时)",
"enum": ["跳过该行,继续导入", "立即停止,不再导入后续行"],
"default": "跳过该行,继续导入"
}
}
},
"entrypoint": {
"type": "cli",
"command": "item",
"args": ["import", "--file", "{{filePath}}", "--on-error", "{{onError}}"]
}
}
```
规则:
- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。
- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)/ `import`(导入写库)。
- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。
- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增/导入」。
- 点默认「新增/编辑/删除」时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。
- 点默认「导入」时**固定**打开宿主导入面板;确认后若有 `replaces:import` 则调用该 Action(`interaction` 不决定是否再弹技能表单)。
- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。
- `import` 约定:`filePath`(或 `file`)+ 可选 `onError`(`skip`/`abort` 或上例中文枚举);技能自行解析文件与表头。
- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。
- **列元数据复用(lookup)**:Action 表单(含 toolbar/batch 弹窗与 create/update 抽屉)可将 `inputSchema` 字段对齐业务列,复用中文名与 lookup 下拉:
- 优先让属性名 = 物理列名(如 `platform_key`);
- 或 `bind.columnMapping`:`{ "platform": "platform_key" }`;
- 或字段上写 `"column": "platform_key"`。
- 当前表没有对应列时,用字段内联 `lookup`(见下文「跨表引用:Lookup」)。
- **不要**在 actions.json 里写死会过期的全量平台 `enum`,也**不要**让用户手填内部编号。
## `interaction`(点击交互方式)
与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。
| 值 | 数据管理体验 | 典型场景 |
|----|--------------|----------|
| `direct` | **一点即跑**,无弹窗 | 打开目录、打开浏览器、无参只读、行内主键已映射 |
| `confirm` | 轻量确认弹窗(无参数区;文案优先 `confirmation.message`) | 处理待发送、删除前确认、有副作用但无可编辑入参 |
| `form` | 完整参数表单(现状默认路径) | 导入、查询条件、需用户改写的字段 |
| `auto` / 省略 | 宿主推断:有**可编辑**参数 → `form`;仅有 `confirmation` → `confirm`;否则 → `direct` | 旧技能兼容;新技能建议显式写出 |
```json
{
"id": "reveal-profile",
"label": "打开目录",
"description": "打开该账号的浏览器 Profile 目录。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "title": "账号 ID", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["reveal-profile", "{{accountId}}"]
}
}
```
```json
{
"id": "open-browser",
"label": "打开浏览器",
"description": "用该账号持久化 Profile 打开系统浏览器,仅供肉眼核对。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "direct",
"bind": {
"tables": ["accounts"],
"inputMapping": { "accountId": "$row.$pk" }
},
"inputSchema": {
"type": "object",
"required": ["accountId"],
"properties": {
"accountId": { "type": "number", "title": "账号 ID", "readOnly": true }
}
},
"entrypoint": {
"type": "cli",
"command": "account",
"args": ["open", "{{accountId}}"]
}
}
```
可选 `interactionByPlacement`:同一 Action 在不同入口用不同交互(例如 `row: "direct"`、`toolbar: "form"`)。值只能是 `direct`/`confirm`/`form`,不能写 `auto`。
**边界:**
- **cron 配置**:仍展示可编辑参数;`confirmation.message` 可作为配置页风险提示(由宿主决定)。
- **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。
- 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。
## Action 按钮中文名(`label`,参考)
`label` 是数据管理 **toolbar / row / batch** 以及技能详情直调上用户看见的短按钮名。本节是**参考契约,不是白名单**:行业专有动作(私信、审单、报关登记等)按业务起名。同类通用动作尽量用同一动词,避免每个技能一套叫法。
**写法:** 短中文,动词或「动词 + 对象」(约 2~8 字)。同一能力用**一个** Action 换 `placements`,不要为入口复制同义按钮。
以后增补:在下表加行,并在模板 `CHANGELOG.md` 记一笔。废弃说法挪到「不要叫」,不要直接删行。
### 宿主已有,技能不要再做一套同义按钮
数据管理宿主已提供:**查看 / 编辑 / 删除 / 新增 / 导入 / 导出 / 刷新**。
技能要用 CLI 接管增删改导入时用 `replaces`(见上文),**不要**再把同义按钮挂到 `placements: ["row"]` 或 `toolbar`(例如「编辑平台」「删除 Key」与底下宿主「编辑」「删除」叠两套)。用户仍点宿主那几个字。
### 建议动词(有则用之)
| 类型 | 建议 `label` | 不要叫 | 说明 |
|------|----------------|--------|------|
| 基础设施 | 运行检查、查看版本、查看配置位置、初始化数据 | 健康检查、版本信息、配置路径、初始化数据库 | 多挂 `skill-detail` |
| 打开 | 打开浏览器、打开目录、打开文件 | 打开文件位置、在资源管理器中打开、用浏览器打开 | 见下节静默成功 |
| 改库 | 新增…、编辑…、删除…、启用/停用、设置默认…、加入待…、重置为待… | 表记录用「添加」;无必要拆成「启用」「停用」两个按钮 | 对象名可带,如「新增账号」 |
| 外源入库 | 同步数据、全量同步、采集…、扫描… | 把宿主「刷新」叫成同步;把导出 CSV 叫成同步 | 默认用「同步数据」;全量才写「全量同步」 |
| 队列与探测 | 处理待…、测试 API、测试网页、抽检本条、抽检所选、批量抽检 | 行 / 勾选 / 工具栏三个入口都叫「抽检」 | 行=本条,勾选=所选,不管勾选的批量=批量 |
| 生成发布 | 生成 HTML、生成…、发布到… | 用「导出」代替宿主表导出 | 固定 id `render-html` 的 label 用「生成 HTML」 |
### 打开类:一点即开,成功不提示
打开目录 / 打开浏览器 / 打开文件:`interaction: "direct"`,`executionProfile` 通常 `"sync"`。
- **成功**:不要输出用户可见成功文案。若打印 JSON,令 `"message": ""`(宿主据此不弹「执行成功」)。操作系统已经弹出目录或浏览器,就是反馈。
- **失败**:仍 `ERROR:<CODE>:`(可加 `HINT:`)。
- **不要**用宿主「查看」表示打开目录或浏览器——「查看」是只读详情。
## `hoverHint`(工具栏悬停,可选)
与 `label` / `description` / `confirmation.message` **独立**。只决定数据管理**顶栏技能按钮**鼠标悬停时显示什么。
- **省略 / 空白** ⇒ 不显示悬停,**不回退**灰色说明或确认黄条。
- 短按钮名已经够懂时**不要写**,避免噪音。适合:业务黑话、会覆盖本机/远程、不点进去看不出后果。
- 悬停文案不要写成「确认开始?」;那是点下去之后黄条的口吻。
- 当前宿主实现为**工具栏技能按钮**;宿主内置删除/刷新/新增/导入/导出不走本字段。
- 需宿主已支持 `hoverHint`;更旧宿主会忽略未知字段(模板 Schema 仍会校验新技能)。
```json
{
"id": "kb-sync-feishu",
"label": "拉取到本机",
"description": "把飞书知识库覆盖到本机工作区。未发布的本地修改会丢失。",
"hoverHint": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。",
"placements": ["toolbar"],
"executionProfile": "async",
"interaction": "confirm",
"confirmation": { "message": "将用飞书覆盖本机知识库,未发布的本地修改会丢失。确认开始?" },
"bind": { "tables": ["kb_sources"] }
}
```
## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5)
与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。
- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。
- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。
- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。
- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。
### `hostEffects`(UI 副作用简写)
```json
{
"hostEffects": {
"onSuccess": ["reloadTable"],
"onFailure": ["clearSelection"]
}
}
```
| 字段 | 触发时机 |
|------|----------|
| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) |
| `onFailure` | **sync** 失败;或 **async** **启动被拒** |
闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。
**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。
### `steps`(有序编排,推荐)
```json
{
"steps": [
{ "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" },
{ "when": "afterSuccess", "do": "reloadTable" },
{ "when": "afterFailure", "do": "clearSelection" }
]
}
```
| `when` | 含义 |
|--------|------|
| `before` | 调用 CLI **之前** |
| `afterSuccess` | 同 `hostEffects.onSuccess` |
| `afterFailure` | 同 `hostEffects.onFailure` |
| `do` | 说明 |
|------|------|
| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects |
| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 |
| `deleteRows` | 删指定表行;须 `table` |
规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。
典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。
```json
{
"id": "register-one",
"label": "登记本行",
"description": "更新当前行业务字段。",
"placements": ["row"],
"executionProfile": "sync",
"interaction": "form",
"bind": {
"tables": ["business_records"],
"inputMapping": { "recordId": "$row.$pk" }
},
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }],
"entrypoint": {
"type": "cli",
"command": "register",
"args": ["--id", "{{recordId}}"]
}
}
```
默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例:
```json
@@ -208,6 +617,7 @@ python {skill_root}/scripts/main.py <command> <args...>
- 使用稳定的退出码
- 成功时输出可解析的结构化结果
- **例外(打开类)**:打开目录 / 打开浏览器 / 打开文件成功时不要输出用户可见成功文案;若打印 JSON,令 `"message": ""`。失败仍走下一款错误约定
- 失败时输出稳定错误信息
- 对可预期错误提供稳定错误码
- 对用户可处理的问题提供 `HINT`
@@ -228,12 +638,46 @@ HINT: <next step>
| 值 | 含义 |
|----|------|
| `toolbar` | 数据管理表顶栏技能按钮(须配合 `bind.tables`) |
| `row` | 数据管理行内操作(建议 `bind.tables` + `inputMapping`;表需有主键) |
| `row` | 数据管理**行级入口**(建议 `bind.tables` + `inputMapping`;表需有主键)。技能声明后,由宿主呈现为操作列链接、**行右键菜单**和/或行内「⋯」等壳;**不**新增 `row-context-menu` 等 placement |
| `batch` | 数据管理批量操作(需勾选行;无勾选时宿主禁用按钮) |
| `skill-detail` | 技能详情页直调(与安装/四 Tab 同属技能市场详情;已声明则须自测) |
| `agent` | Agent 可直接调用(`run_skill_action`) |
| `cron` | 定时任务「技能直调」 |
### 行级入口 `row`(宿主呈现契约)
- **技能决定有什么**:`placements` 含 `"row"`(且通过 `bind.tables` / policy 等过滤)的 Action,构成该表行的技能菜单项集合;配什么就出现什么。
- **宿主决定怎么摆**:可用操作列、行右键、行内「⋯」等呈现**同一批** Action;点击均走同一 `launch`(`placement: "row"`),`interaction` / `executionProfile` 与壳无关。
- **顺序**:各壳内技能项顺序 = 下方「数据管理技能按钮顺序」契约(`actions` 数组相对顺序)。
- **宿主内置项**:查看 / 编辑 / 删除由宿主提供,**不是**技能 `actions.json` 配置出来的;若出现在行右键,须与技能项分组(例如分隔线后),且不得要求技能再配一份菜单 DSL。
- **禁止**:为右键单独发明 placement、菜单注册表或第二套 Action 列表。
### 数据管理技能按钮顺序(宿主正式契约)
宿主**没有**单独的 `sortOrder` / `uiOrder` 字段。同一 `placement`(`toolbar` / `row` / `batch`)下,技能按钮的展示顺序 = `actions` 数组中**匹配该项 placement(且通过 `bind.tables` 过滤)的相对先后**。
规则:
1. 想调整顺序 → **调整 `actions` 数组先后**(不必改 `id` / `label`)。
2. 无关 placement 的 Action(如仅 `skill-detail` 的 `health`)夹在中间**不影响** toolbar/row 的相对顺序;宿主按匹配子集保序渲染。
3. 同一 Action 可同时挂多个 placement;它在各入口的位置,分别是该入口匹配子集里的相对位置。
4. **宿主内置按钮不参与本契约、位置固定**:
- 表顶栏右侧:删除(有勾选时)/ 刷新 / 新增 / 导入 / 导出 —— 技能 toolbar/batch 按钮在左侧,不能与右侧内置按钮混排。
- 行级:技能 `row` 项按本顺序出现在操作列 / 行右键(及可选「⋯」)中;宿主「查看」→(非只读)「编辑」→「删除」为内置项,位置由宿主固定(操作列常在技能项之后;右键中可与技能项分组)。
5. **表顶栏全部直接展示,无「更多操作」溢出菜单**:匹配到的 toolbar/batch 技能按钮一律渲染(顺序仍按本契约)。**行级**不以「顶栏溢出菜单」模型截断;行右键是 `row` 的正式呈现壳之一,不是 toolbar 式「更多操作」截断。
6. **表顶栏宽窄布局(宿主行为,技能无需配置)**:
- 够宽:左右各横排一行(左技能按钮 + 右宿主工具)。
- 不够宽:保持左右分栏,栏内按钮上下竖排(左栏技能、右栏刷新/导出等)。
禁止:
- 臆造 `sortOrder` / `order` / `priority` 等字段指望宿主排序(模板 Schema `additionalProperties: false` 会拒收)。
- 臆造 `tooltip` / `title` 等字段当悬停(用可选 `hoverHint`;不要把 `confirmation.message` 复用成悬停)。
- 依赖或宣传表顶栏「更多操作」溢出菜单(宿主已取消该能力)。
- 把「表格行数据默认排序」与按钮顺序混淆——行排序见 [`SCHEMA.md`](SCHEMA.md) 的 `default_sort_json`。
- 把「默认展示列」与 `visible=0` 硬隐藏混淆——默认展示列见 [`SCHEMA.md`](SCHEMA.md) 的 `default_visible_columns_json`。
- 新增 `row-context-menu` 等 placement,或要求技能再维护一份与 `row` 平行的菜单清单。
### Action 类型 × placements(常见示例,不是限制)
下表只表示**常见用法示例**。实际 `placements` 以 manifest 配置为准;**`sync` / `async` 不影响展示入口**。已声明的入口都要在宿主自测,不要只测其中一条。
@@ -248,7 +692,7 @@ HINT: <next step>
| 导入 / 重置 | sync 或 async | skill-detail, agent | 可选 |
| 单条副作用 RPA | async(建议) | agent, skill-detail | **建议** |
| 批量顺序 pick RPA | async(建议) | toolbar, cron, agent | **建议** |
## 同步数据 vs 导出当前表 vs 特殊业务报告
## 同步数据 vs 导出当前表 vs 特殊业务报告 vs HTML 页面
不要混用「导出」一词:
@@ -256,17 +700,228 @@ HINT: <next step>
|------|------|--------|
| **同步数据**(sync / refresh / collect) | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 技能本地数据库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` Action |
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的 **xlsx/csv/json 等文件** | 仅当系统导出无法满足时,才提供独立 `export-*` Action;文件落 `output/` |
| **HTML 页面报告** | 可在数据管理侧栏预览的整页 HTML | 技能写入 `{skill_data}/html/*.html` + Action id **`render-html`**(见下节) |
同步型 Action 默认:读外部数据、写技能自己的库、**不**生成 CSV/Excel/JSON 文件、不重复实现宿主表导出。兼容旧 export 命令若需要刷新数据,必须调用同一个 sync 业务内核。通用同步语义见 [`SCHEMA.md`](SCHEMA.md) 与 [`../development/SKILL_ACTION_RUNTIME.md`](../development/SKILL_ACTION_RUNTIME.md)。
## HTML 页面报告(数据管理预览)
> 一句话:**技能负责生成并覆盖 `html/*.html`;宿主负责发现、预览、用 `render-html` 触发重新生成。**
### 何时用
- 需要**可浏览的整页报告**(KPI + 表格 + 自带样式),而不是再导出一张表
- 与 `export-*` / 宿主表导出**并列**:HTML 走 `html/`;文件型特殊报告仍走 `output/` + `export-*`
### 目录与发现(宿主契约)
| 项 | 约定 |
|----|------|
| 路径 | `{skill_data}/html/*.html`,**只扫一层**(子目录忽略) |
| helper | `util.runtime_paths.get_html_dir()`;写入用 `service.html_page_service.write_html_page` |
| 侧栏名 | 取文件内 `<title>`(缺省则用文件名) |
| 预览 | 宿主 iframe 预览;右键:重新生成 / 浏览器打开 / 刷新 |
| 模板资源 | 建议放 `assets/html/*.html`(安装包内),渲染后**写出**到数据目录 `html/` |
### Action:`render-html`(固定 id)
宿主「重新生成」**硬编码**查找 Action id `render-html`(见 `service.html_page_service.HOST_HTML_RENDER_ACTION_ID`)。技能未提供该 id → 提示「该技能未提供生成 HTML」。
推荐形态:
| 字段 | 建议 |
|------|------|
| `id` | **必须** `render-html` |
| `executionProfile` | `sync` |
| `interaction` | `direct` |
| `placements` | `toolbar` + `skill-detail`(配合表顶栏与 HTML 右键);可选 `agent` |
| `bind.tables` | 数据**来源表**(含 `toolbar` 时必填);宿主从 HTML 页重新生成时会用 bind 补齐跑表上下文 |
| `steps` | `afterSuccess` → **`refreshCatalog`**(重扫 `htmlPages`;不要只 `reloadTable`) |
| `entrypoint` | CLI 子命令(如 `render-html`)调用同一写入内核 |
完整 JSON 形状见 [`../assets/examples/render-html.action.example.json`](../assets/examples/render-html.action.example.json)。最小页面壳见 [`../assets/html/example-report.html`](../assets/html/example-report.html)。
### 实现要点
1. 用 `load_html_template` + `render_html_placeholders` + `write_html_page`(原子 `*.tmp` + `os.replace`)覆盖同名文件;**不要**每次另存带时间戳的副本(除非产品明确要求历史版本)。
2. 页头时间建议用**写出时刻**(「报告生成于」),不要误用业务表里过期的 `generated_at` 冒充「刚重新生成」。
3. 可选:数据重算成功后调用静默写入(吞掉错误),保持侧栏报告与库一致。
4. 权限:按业务决定谁能生成(管理员 / 角色);在 CLI/`cmd_render_html` 内强制校验。
5. CHANGELOG / 用户说明:模板或样式升级后,提醒「请在数据管理对报告执行一次重新生成」。
### 验收(复制后自测)
- [ ] `html/` 下出现 `.html`,数据管理侧栏能看到报告项,预览正常
- [ ] 表顶栏(若声明 toolbar)或报告右键「重新生成」能跑通;成功后预览刷新
- [ ] 未登录/无权限用户被拒绝,且有可读错误
- [ ] 成功路径带 `refreshCatalog`(或等价让侧栏/预览看到更新)
## inputSchema 与参数表单
当前宿主稳定支持:
### 跨表引用:Lookup(宿主已支持;勿让用户填编号)
- `object` / `string` / `number` / `boolean`
当 Action 参数要引用**同库另一张业务表**的一行(话术模板、平台、账号等)时,必须用 Lookup 下拉,**禁止**做成「请填写编号」的数字框(用户记不住内部 id,体验差)。
| 场景 | 怎么配 | 说明 |
|------|--------|------|
| 当前表已有同含义列,且列上已是 CRUD lookup | `column` / `bind.columnMapping`,或属性名 = 列名 | toolbar/batch 弹窗与 create/update 抽屉都会复用该列已解析的选项 |
| 当前表没有该列(如在「潜客」表上选「话术模板」) | `display_type: "lookup"` + 字段内联 `lookup` 对象 | 打开表单时宿主按当前技能库解析选项 |
内联 `lookup` 与列元数据 `options_json` **同构**(见 [`SCHEMA.md`](SCHEMA.md)「跨表引用 lookup」):
```json
{
"templateId": {
"type": "number",
"title": "话术模板",
"description": "仅在正文留空时生效;选 -- 表示自动挑选。",
"display_type": "lookup",
"lookup": {
"ref_table": "message_templates",
"value_column": "id",
"label_column": "name",
"extra_column": "channel",
"filter_column": "is_active",
"filter_value": 1
}
},
"engineIds": {
"type": "string",
"title": "监测引擎",
"description": "可多选;提交为逗号分隔编号。",
"display_type": "lookup",
"lookup": {
"ref_table": "engines",
"value_column": "id",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
"multiple": true
}
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `ref_table` | 是 | 同库被引用表 |
| `value_column` | 是 | 提交给 CLI 的值(业务 id / 编号) |
| `label_column` | 是 | 主文案列 |
| `extra_column` | 否 | 第三展示列(任意列名,不限「备注」) |
| `filter_column` / `filter_value` | 否 | 等值过滤 |
| `multiple` | 否 | `true` = 多选;省略/`false` = 单选(默认) |
**下拉/选项展示(宿主统一,技能只配列):** 单选为 `{value} · {label}`;有非空 `extra_column` 时为 `{value} · {label}({extra})`。例:`2 · 通用私信-留资`、`2 · 通用私信-留资(dm)`。
**多选存值与模板:** Action / CRUD 存 **`TEXT` 逗号分隔 id**(如 `1,2,5`);`{{engineIds}}` 原样替换进 CLI。多选字段在 `inputSchema` 里用 `type: "string"`(逗号串),不要用尚未稳定的 JSON array 当业务控件。
**多选控件(宿主策略):** 选项少(建议 ≤6)用勾选列表;选项多用字段内 Chip + 可搜索下拉(不是再开业务弹窗)。
**可选「自动」(单选常见):** 字段不要放进 `required`;不要用 `minimum: 1` 卡死留空。宿主会省略空的可选数字参数,避免「提交的数据无效」。
**反模式(勿做):**
- 标题写成「某某编号」,说明里教用户去左侧表抄 id
- 用静态 `enum` 罗列会增长/变更的业务行(会过期)
- 在技能里自己拼假 label 列代替宿主统一格式(难维护)
- 另造 `lookup_multi` / 第二套 display_type(应只加 `multiple: true`)
形状参考:[`assets/examples/lookup-action.example.json`](../assets/examples/lookup-action.example.json)。
### 表单字段类型对照(须与 CRUD 对称)
Skill Action 弹窗与数据管理「新增 / 编辑」使用**同一套控件语义**。CRUD 侧配置见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」;本表只写 Action 怎么配。
| 控件语义 | Action `inputSchema` 怎么写 | CRUD 怎么写(对照) |
|----------|----------------------------|---------------------|
| 单行文本 | `type: string` | `display_type: text` 或缺省 |
| 多行文本 | `display_type` 或 `format`: `textarea` | `display_type: textarea` |
| JSON 长文本 | `type: object`/`array`(以 JSON 字符串编辑)或 `textarea` | `display_type: json` / `textarea` |
| 数字 | `type: number` | `display_type: number` 或 INT/REAL/NUMERIC |
| 开关 | `type: boolean` | `display_type: boolean` |
| 枚举下拉 | `enum: [...]` | `options_json` 数组 |
| 关联下拉(Lookup) | 内联 `display_type: lookup` + `lookup`(可选 `multiple`),或绑列 | 同左;多选存 `TEXT` 逗号分隔 id |
| 密码 | `sensitive: true` | 列 `sensitive` |
| 日期(日) | `display_type` 或 `format`: `date` | `display_type: date` → 存 `YYYY-MM-DD` |
| 日期时间 | `display_type` 或 `format`: `datetime_unix_seconds` | 同左;**存/传 Unix 秒** |
| URL(薄层) | `display_type` 或 `format`: `url` | `display_type: url` |
| Email(薄层) | `display_type` 或 `format`: `email` | `display_type: email` |
| 说明灰字 | `description` | `_jiangchang_columns.description` |
| 表格横幅 / 列头「?」 | (无;Action 不用) | `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help` |
| 默认值 | `default` | SQLite 列 `DEFAULT` |
| 只读 | `readOnly: true` | 列 `editable: 0` / 宿主只读规则 |
| **条件显隐** | **`visibleWhen`**(见下节) | **本版 CRUD 无对称**;仅 Action / 定时任务参数表单 |
**本版不做(勿宣传):** 文件上传、复杂 `oneOf` / nested object 专用表单;`visibleWhen` 多条件 AND/OR 树、跨 Action 依赖、CRUD 新增/编辑对称显隐。
(**Lookup 多选**已支持:同一 `lookup` + `multiple: true`,见上文;不是任意标签数组。)
日期时间约定:
- Action 提交与 CRUD 写库统一用 Unix **秒**整数(与 SCHEMA 标准时间字段一致)
- `date` 只表示日历日,值为 `YYYY-MM-DD`
- 脏/无法归一的旧预设:日期控件可回退为普通文本,避免卡死表单
### 当前宿主稳定支持(摘要)
- 根:`object`;属性:`string` / `number` / `boolean`(`object`/`array` 仅作 JSON 多行编辑兼容,新技能慎用)
- `enum` / `default` / `required` / `sensitive` / `readOnly`
- `display_type` / `format`:`textarea` | `date` | `datetime_unix_seconds` | `url` | `email` | **`lookup`**
- 字段内联 `lookup`(与 CRUD `options_json` lookup 对象同构;可选 `extra_column`、`multiple`)
- 属性级 `description`(表单标签下方灰字说明)
- 简单模板替换(`{{fieldName}}`)
- 可选空数字字段提交时由宿主省略,避免 `""` 触发 number 校验失败
- **字段条件显隐 `visibleWhen`**(可选):依赖同表单其它字段值决定是否展示;隐藏字段不进提交、不校验 `required`
### 字段条件显隐(`visibleWhen`)
用于「先选模式,再出对应参数」类表单。写在 `inputSchema.properties.<field>` 上(**不是** action 顶层字段)。
> **消歧义:** 本文的 `visibleWhen` = **Action 参数表单控件显隐**。
> [`POLICY.md`](POLICY.md) L3 预留的 `visibleWhen` = **行态/权限策略**(未冻结),**不是**同一能力。勿混用、勿互相替代。
```json
"keyword": {
"type": "string",
"title": "关键词",
"visibleWhen": { "field": "collectMode", "equals": "按关键词采集" }
},
"authorUrl": {
"type": "string",
"title": "作者主页链接",
"visibleWhen": { "collectMode": "按作者主页采集" }
}
```
支持形态:
| 写法 | 含义 |
|------|------|
| `{ "field": "x", "equals": "a" }` | `x === a` 时显示 |
| `{ "field": "x", "in": ["a", "b"] }` | `x` 属于集合时显示 |
| `{ "x": "a" }` / `{ "x": ["a","b"] }` | 单键简写,同上 |
宿主行为(需已支持该字段的匠厂宿主;更旧版本会忽略未知字段 → 参数平铺全显):
- 数据管理 / 定时任务参数表单按当前值求值并隐藏
- 若依赖字段自身因 `visibleWhen` 隐藏,则下游字段一并隐藏(避免切模式后残留)
- 提交与服务端 `validateActionInput` 会剥离隐藏字段;隐藏字段不校验 `required`
- 无 `visibleWhen` 的字段保持始终可见(兼容旧技能)
形状参考:[`assets/examples/visible-when-action.example.json`](../assets/examples/visible-when-action.example.json)。
互斥模式**优先**用 `visibleWhen`,不要只靠 `description` 灰字写「仅某某时填写」。
**与数据管理「新增 / 编辑」表单的分工(勿混用配置入口):**
| 表单 | 短标题 | 说明灰字 | 默认值 | 控件类型 |
|------|--------|----------|--------|----------|
| Skill Action 弹窗 | `inputSchema.title` | `inputSchema.description` | `inputSchema.default` | `type` + `display_type`/`format` |
| 数据管理新增/编辑 | `_jiangchang_columns.display_name` | `_jiangchang_columns.description` | 物理列 SQLite `DEFAULT` | `display_type` + 物理类型推断 |
数据管理**表格**上方说明与列头「?」用 `_jiangchang_tables.grid_help` / `_jiangchang_columns.grid_help`(空则不渲染),**不要**用 `description`。见 [`SCHEMA.md`](SCHEMA.md)「表格说明 `grid_help`」。
CRUD 侧宿主义务与禁止事项见 [`SCHEMA.md`](SCHEMA.md)「表单与展示:稳定字段类型」「新增/编辑表单:字段说明与默认值」。同一业务含义两边文案与控件应对齐,但分别配置。
示例:
@@ -278,12 +933,31 @@ HINT: <next step>
"properties": {
"query": {
"type": "string",
"title": "查询条件"
"title": "查询条件",
"description": "支持关键词或完整业务编号"
},
"limit": {
"type": "number",
"title": "返回数量",
"description": "最多返回多少条;填 0 表示不限制(若业务支持)",
"default": 50
},
"dueDate": {
"type": "string",
"title": "截止日期",
"display_type": "date",
"description": "日历日,格式 YYYY-MM-DD"
},
"publishAt": {
"type": "number",
"title": "发布时间",
"display_type": "datetime_unix_seconds",
"description": "提交值为 Unix 秒"
},
"homepage": {
"type": "string",
"title": "主页链接",
"display_type": "url"
}
}
}
@@ -297,26 +971,31 @@ HINT: <next step>
"pickCount": {
"type": "number",
"title": "处理数量",
"description": "从队列选取 pending 记录条数",
"default": 1,
"minimum": 1
"description": "从队列选取「可操作」状态记录的条数(具体状态名由本技能定义,见 WORK_ITEM_STATUS)"
}
}
```
默认**不要**把失败/成功/跳过混进该 pick;重做靠人工改回可操作态。详见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。
若还需要默认篇数,可在同一 `properties` 里为 `pickCount` 增加 `"default": 1`、`"minimum": 1`。
约定:
- `boolean` 在宿主中渲染为开关
- `enum` 在宿主中渲染为选择框
- `type: number` 渲染为数字输入
- `display_type`/`format: date` 渲染为日期(日);`datetime_unix_seconds` 渲染为日期时间并按 **Unix 秒**提交
- `display_type`/`format: url` / `email` 为薄层输入类型(校验从宽,勿当强校验网关)
- `required` 字段在提交前校验
- `sensitive` 字段不得出现在低信任入口
- `readOnly: true` 时数据管理表单只读展示该字段(仍随提交传入);适合行内/勾选预填的主键、编号列表
- `placements` 含 `batch` 的动作在数据管理表顶常驻,无勾选时禁用(与表删除一致)
- 需要确认的副作用操作使用 `confirmation.message`
- 短名看不懂或会覆盖数据时,用独立的 `hoverHint`;不要把确认黄条复用成悬停
- 模板参数使用 `{{fieldName}}`,且必须在 `inputSchema.properties` 中声明
- `entrypoint.args` 项当前只允许 `string`、`number`、`boolean`;不允许对象或 `null`
- **不要**在当前模板中宣传 `nested object`、`oneOf`、复杂数组等尚未稳定支持的能力
- **不要**在当前模板中宣传文件上传、任意 JSON 多选数组、`oneOf`、复杂 nested object 等尚未稳定支持的能力;Lookup 多选请用 `lookup.multiple`
## 风险与入口配置
- 查询、诊断、版本检查可以放到 `skill-detail` 或 `agent`

View File

@@ -0,0 +1,210 @@
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
> **目标:** 冻结「技能声明某表某行某列的语义呈现 → 宿主映射主题样式」的通用能力,供任意业务场景复用。
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)。
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
---
## 0. 一句话
技能可以声明:**哪张业务表、哪一行、哪一列**需要特殊标色/强调(语义 `tone` + 可选说明)。
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子**上。
---
## 0.1 能力档位
| 档位 | 技能行为 | 宿主行为 |
|------|----------|----------|
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应单元格的语义样式;可选汇总 `alert: true` 做进页提醒 |
多数技能保持 **P0**。需要视觉强调时再启用。
---
## 1. 职责边界
| 角色 | 负责 | 不负责 |
|------|------|--------|
| **技能** | 按**当前业务数据**算出哪些格子要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
---
## 2. 文档形状(冻)
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合 Schema。
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"table": "business_records",
"rowKey": "id",
"generatedAt": "2026-08-10T06:00:00Z",
"cells": [
{
"rowId": "42",
"column": "qty",
"tone": "danger",
"message": "库存低于安全线",
"alert": true
},
{
"rowId": "42",
"column": "status",
"tone": "warning",
"message": "待审核"
}
]
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `schemaVersion` | 是 | 固定 `1` |
| `skill` | 是 | 与 slug 一致 |
| `table` | 是 | 当前文档作用的业务表 |
| `rowKey` | 是 | 行匹配列(`id` / `feishu_record_id` 等) |
| `generatedAt` | 否 | 排障用 |
| `cells` | 是 | 数组;**空数组 = 该表清除全部特殊呈现** |
### 2.1 `cells[]` 项
| 字段 | 必填 | 说明 |
|------|------|------|
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
| `tone` | 是 | 见 §3 |
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
---
## 3. `tone` 闭集(冻)
| tone | 语义(技能怎么选) | 宿主怎么呈现(示例,主题可调) |
|------|-------------------|-------------------------------|
| `danger` | 必须立刻处理 / 已违规 / 严重超时 | 强强调(如浅红底) |
| `warning` | 即将到期 / 需注意 | 中等强调(如浅黄) |
| `info` | 提示性标记 | 轻强调(如浅蓝) |
| `success` | 正向完成态(少用在「待填」场景) | 浅绿等 |
| `muted` | 弱化 / 次要 | 灰底或灰字 |
**禁止:** 技能下发原始 `#RRGGBB`、任意 CSS class、内联 style。
以后若扩展色板,只能**追加新 tone token** 并升契约,不能开放任意色值。
「技能配置什么就显示什么」在本契约中的含义是:技能选择 **哪一格 + 哪个 tone + 什么说明**;**像素级颜色由宿主主题决定**,以保证多技能 UI 一致与安全。
---
## 4. 推荐持久化(给宿主实现)
v1 **推荐**技能将「当前表」的呈现写入用户库旁路表(与业务表分离,**不是**多余业务超时列):
```sql
CREATE TABLE IF NOT EXISTS _jiangchang_cell_presentation (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_name TEXT NOT NULL,
row_key TEXT NOT NULL,
row_id TEXT NOT NULL,
column_name TEXT NOT NULL,
tone TEXT NOT NULL,
message TEXT,
alert INTEGER NOT NULL DEFAULT 0,
generated_at TEXT,
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE (table_name, row_id, column_name)
);
```
约定:
- 重算某表时:**先删该 `table_name` 全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在此表,则按当前页行的 `rowKey` 值匹配并着色。
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
未创建该旁路表 = P0,宿主忽略。
---
## 5. 在 `actions.json` 中启用(可选,推荐)
与 `policy` 类似,顶层可选:
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"cellPresentation": {
"enabled": true
},
"actions": []
}
```
| 字段 | 说明 |
|------|------|
| `cellPresentation` | 省略 = P0 |
| `cellPresentation.enabled` | 仅 `true` 表示宿主应尝试加载呈现;`false`/缺省等同 P0 |
v1 **不**要求单独 CLI entrypoint(与 policy 不同);技能在 `sync` / 写路径 / `init-db` 后自行维护旁路表即可。若未来需要「仅刷新呈现」Action,用普通 Skill Action 即可,不必扩展本启用块。
---
## 6. 何时重算(技能必须遵守)
至少在以下时机用**同一套 domain 规则**重算并覆盖该表呈现:
1. 全量/增量把外部数据写入本地投影之后(如同步成功)
2. 会改变「是否仍需强调」的写路径成功之后(新建、更新、登记等)
3. (可选)用户显式「刷新呈现」类 Action
进页 Toast:宿主可在加载表后统计当前页或全表 `alert=1` 的条数;**0 则不打扰**。
提醒关闭 = 重算后没有对应 cell,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
---
## 7. 与其它契约的关系
| 契约 | 关系 |
|------|------|
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色 |
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
---
## 8. 反模式
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
- 技能下发 CSS / 内联颜色
- 只在首次超时时打标、填完不重算导致红字残留
- 宿主写死某个技能 slug 的特殊染色逻辑
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
---
## 9. 示例场景(说明用途不限)
| 场景 | column | tone | message 例 |
|------|--------|------|------------|
| 打单超时未填 | `print_done_at` | `danger` | 打单已超 1 天未填 |
| 库存低于安全线 | `qty` | `warning` | 低于安全库存 |
| 审核驳回 | `status` | `danger` | 已驳回,请修改 |
| 选填备注缺失但不紧急 | `notes` | `muted` | 建议补充备注 |
完整 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。

View File

@@ -24,10 +24,10 @@ python {baseDir}/scripts/main.py <command> …
| 类型 | 本技能命令(自填) | Agent |
|------|--------------------|-------|
| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` |
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。若提供 HTML 报告,「重新生成」须有 Action id **`render-html`**(可同时进短 CLI 白名单);见 ACTIONS.md「HTML 页面报告」。
## 最小命令
@@ -45,15 +45,15 @@ python {baseDir}/scripts/main.py init-db
- **`config-path`**:输出 JSON,包含 `skill`、`env_path`、`example_path`。
- **`version`**:输出 JSON(`version`、`skill`)。
- **`init-db`**:幂等创建/迁移本地 SQLite(含 `_jiangchang_*` 展示元数据)。成功输出一行 JSON(`ok` / `skill` / `db_path`),退出码 `0`。宿主可在技能安装或更新成功后静默调用;不要并入 `health`。
- **`policy`(可选)**:仅当 `actions.json` 顶层启用 Data Policy 时需要;向 stdout 输出 Snapshot JSON(见 `references/POLICY.md`)。模板默认不实现;多数技能保持 L0 即可。
- **`run` / RPA / 长任务**:CLI 仍是同一内核;**Agent 禁止**用 bash 干等长任务结束。须走宿主 Skill Action(`run_skill_action`),且该 Action 一般为 `executionProfile: "async"` → **任务中心**。长时间无 stdout **不代表卡死**;进度看任务中心 / Run Journal。
- **排查顺序**(由快到慢):
1. `python {baseDir}/scripts/main.py logs`
2. `python {baseDir}/scripts/main.py log-get <log_id>`
3. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等)
4. Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`
5. RPA artifacts / `video_log` / 失败截图(开发规范见 `development/RPA.md`);未开启录屏时可能只有截图或日志
1. 任务中心 / Run Journal `{JIANGCHANG_DATA_ROOT}/.jiangchang/runs/{job_id}.jsonl`
2. 统一日志文件 `{JIANGCHANG_DATA_ROOT}/{JIANGCHANG_USER_ID}/logs/jiangchang.log`(含 `trace_id`、`task_failed` 等)
3. 业务表(宿主数据管理;有才建)
4. RPA artifacts / `video_log` / 失败截图(开发规范见 `development/RPA.md`);未开启录屏时可能只有截图或日志
- **录屏默认关闭**:模板 `.env.example` 为 `OPENCLAW_RECORD_VIDEO=0`;若未开启录屏,任务**可能不会**打印录屏路径。
- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `result_summary` 含 video 字段。
- 需要录屏时在用户 `.env` 或进程环境变量中设置 **`OPENCLAW_RECORD_VIDEO=1`**;开启后任务完成时 CLI 应打印录屏路径、录屏日志、视频/音频诊断(见 `task_run_support._print_video_summary`),且 `finish()` 含 video 字段。
## config-path(配置路径)
@@ -81,12 +81,8 @@ python {baseDir}/scripts/main.py init-db
```bash
python {baseDir}/scripts/main.py run
python {baseDir}/scripts/main.py run -t <target> -i <input_id>
python {baseDir}/scripts/main.py logs
python {baseDir}/scripts/main.py logs --task-type demo --status failed
python {baseDir}/scripts/main.py log-get <log_id>
```
| 命令 | 用途 | Agent 注意 |
|------|------|------------|
| `run` | 执行一次任务(常为长任务 / RPA) | **走 `run_skill_action`**,不要 bash poll |
| `logs` / `log-get` | 查任务日志 | 可用短 CLI |

383
references/POLICY.md Normal file
View File

@@ -0,0 +1,383 @@
# Skill Data Policy 契约(v1.0)
> **状态:正式契约 v1.0(`schemaVersion` 1)。**
> **目标:** 冻结「启用方式 + Snapshot 最小字段 + L2 scope 白名单」,使多数技能(L0)零配置,需要权限的技能(L1/L2)有稳定扩展面。
> **非目标:** 宿主实现业务用户/角色库;规定飞书/HTTP/本地表等权限数据源;本期不强制 L3(行态 `visibleWhen` 等)。
> **注意:** L3 预留的行态 `visibleWhen` **不是** Action 参数表单的 `inputSchema.properties.*.visibleWhen`(表单控件显隐,见 [`ACTIONS.md`](ACTIONS.md)「字段条件显隐」)。二者同名不同义,勿混用。
> **配套 Schema:** [`../assets/schemas/skill-policy-snapshot.schema.json`](../assets/schemas/skill-policy-snapshot.schema.json);启用块见 [`../assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)。本契约增加第六轴:**policy(当前身份允许什么)**。
---
## 0. 能力档位(必须按此理解)
| 档位 | 技能配置 | 宿主行为 |
|------|----------|----------|
| **L0** | 无 `policy`,或 `policy.enabled !== true` | **与今日完全一致**:不拉 Snapshot,不按身份过滤按钮/行/列 |
| **L1** | `policy.enabled: true`,Snapshot 至少给出 Action 授权 | 数据管理展示 skill Action 前与 allowlist 求交;可选限制宿主内置能力 |
| **L2** | 在 L1 基础上 Snapshot 带 `data.tables[table]` | 列表查询应用声明式 `rows.scope`;列按 `columns` 投影 |
| **L3** | (预留,本契约不冻结行为) | 如**行态** `visibleWhen`、菜单级藏表、`rows.mode=delegate` 等(≠ Action 表单 `inputSchema.visibleWhen`) |
**80% 技能停留 L0。** 启用 policy 的技能必须实现约定 CLI,并在写路径自行再鉴权(见 §7)。
---
## 0.1 已冻结最佳实践(v1.0)
| 议题 | 决定 | 技能开发者怎么做 |
|------|------|------------------|
| Skill Action 授权模式 | **仅** `allowlist` / `open`;v1 **禁止** `denylist` | 有角色就算出允许的 action id 列表;只控行不控按钮时用 `open` |
| `hostCapabilities` | **省略 = 不限制**宿主内置;写出则按 allowlist(`[]` = 全禁内置) | 只控业务按钮时**不要写**该字段;要限导出/删除时再显式列出 |
| scope 运算符 | 保留小白名单(见 §3.4);**禁止** like/raw/嵌套 | 日常优先 `eq` / `in` / `is_null`;比较符按需 |
| `valueFrom` | v1 **仅** `actor.userId` | 角色只在技能内用来算 Snapshot,**不要**用 `actor.roles` 做行过滤 |
---
## 1. 启用字段(冻在 `assets/actions.json` 顶层)
`actions.json` 根对象增加**可选**字段 `policy`(与 `schemaVersion` / `skill` / `actions` 同级)。
### 1.1 形状(L0/L1 必冻)
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"policy": {
"enabled": true,
"entrypoint": {
"type": "cli",
"command": "policy",
"args": ["--table", "{{table}}"]
},
"appliesTo": ["data-management"]
},
"actions": []
}
```
| 字段 | 必填 | 冻结说明 |
|------|------|----------|
| `policy` | 否 | 缺省 = L0 |
| `policy.enabled` | 若存在 `policy` 则**必填** | 仅 `true` 启用;`false` 等同 L0 |
| `policy.entrypoint` | `enabled: true` 时必填 | 与 Action `entrypoint` 同构:`type` 目前仅 `"cli"` |
| `policy.entrypoint.command` | 是 | 子命令名;**推荐**固定为 `policy` |
| `policy.entrypoint.args` | 是 | JSON 数组;可含占位符,由宿主替换后再拼接 CLI |
| `policy.appliesTo` | 否 | 缺省 `["data-management"]`。本期宿主**只实现** `data-management`;其它值忽略但不报错 |
### 1.2 占位符白名单(冻)
| 占位符 | 含义 | 例 |
|--------|------|----|
| `{{table}}` | 当前数据管理选中的**物理表名** | `customs_timelines` |
| `{{skill}}` | 当前技能 slug | 与 manifest `skill` 一致 |
| `{{userId}}` | 当前宿主用户 id | 与注入的 `JIANGCHANG_USER_ID` 一致 |
未列出的 `{{…}}`:**禁止**;宿主应视为契约错误(fail-closed,见 §6)。
### 1.3 显式不放进启用块的内容(防推翻)
以下**不得**出现在 `actions.json.policy` 中(属于技能内部或 Snapshot):
- 角色列表、人员表、飞书 app id、外部 IAM URL
- 具体 allowlist / scope(那是 Snapshot 的事)
- 按角色复制的 Action 定义
### 1.4 Action 级可选提示(不冻为运行时权威)
允许(文档/静态检查用),**宿主不得仅凭此显隐按钮**:
```json
"authz": { "capability": "optional.skill.defined.string" }
```
运行时权威仅为 Snapshot §2 的 `actions`。
---
## 2. Snapshot 最小字段集(`policy` CLI 标准输出)
- 编码:UTF-8 JSON 对象,写在 **stdout**(可先打日志到 stderr/文件,但 stdout 必须是单 JSON)。
- 成功退出码 `0`;业务上「无权限看任何东西」仍是 `0` + 合法 Snapshot(例如空 allowlist),不是靠非 0 表示未授权。
- 非 0 或非合法 JSON → §6 fail-closed。
### 2.1 根对象(L0 不出现;L1 必冻)
| 字段 | 必填 | 类型 | 说明 |
|------|------|------|------|
| `schemaVersion` | 是 | `integer` | 本期冻结为 **`1`** |
| `skill` | 是 | `string` | 必须等于 manifest `skill` |
| `actor` | 是 | `object` | 见 §2.2 |
| `actions` | 是 | `object` | 见 §2.3 |
| `hostCapabilities` | 否 | `string[]` | 见 §2.4;**省略 = 不限制宿主内置按钮**(兼容) |
| `data` | 否 | `object` | 见 §3;省略 = 不限制行/列(L1-only) |
| `extensions` | 否 | `object` | 技能私有扩展;宿主**必须忽略**未知键 |
根级未知字段:宿主**忽略**;**不得**依赖未写入本契约的字段做安全决策。
### 2.2 `actor`(L1 必冻)
| 字段 | 必填 | 说明 |
|------|------|------|
| `userId` | 是 | 非空;应与 `JIANGCHANG_USER_ID` / `{{userId}}` 对齐 |
| `displayName` | 否 | 展示用 |
| `roles` | 否 | `string[]`,角色**码**;仅供展示 / 技能自检。**不得**用于 v1 scope 的 `valueFrom` |
| `attributes` | 否 | `object`,仅允许 JSON 标量值;本期 scope **不**解引用 |
### 2.3 `actions`(L1 必冻)— Skill Action 授权
**最佳实践:** 角色 → 技能内计算 → `allowlist` 的 id 列表。宿主不理解角色名。
```json
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "register-submit"]
}
```
| 字段 | 必填 | 冻结取值 |
|------|------|----------|
| `mode` | 是 | **仅两值**:`"allowlist"` \| `"open"` |
| `ids` | `mode=allowlist` 时必填 | `string[]`;元素为 **`actions.json` 里的 action.id** |
| `ids` | `mode=open` 时 | 必须省略或 `[]`;宿主视为**不限制** skill Action |
规则:
1. 宿主先按现有规则得到候选集:`placements` ∩ `bind.tables`(及 batch 等)。
2. 若 `mode=allowlist`:候选 ∩ `ids`。漏配 id = 少按钮(安全侧失败)。
3. `ids` 中的未知 id:**忽略**(不报错)。
4. `mode=open`:等价「L1 不限制按钮」,可与 L2 行列限制并存。
5. **`denylist` 不是 v1 合法值**;出现则 §6 fail-closed。
`replaces` 接管的默认新增/编辑/删除/导入:视为对应 Action 的 id 是否在 allowlist 中;不在则宿主隐藏该接管按钮(导入按钮是否可用还受 `hostCapabilities` 含 `import` 影响)。`mode=open` 时不按 id 限制 replaces。
### 2.4 `hostCapabilities`(L1 可选冻)
控制数据管理**宿主内置**能力,与 skill `action.id` **命名空间分离**。
本期枚举冻结:`refresh` | `export` | `view` | `edit` | `delete` | `import`。
| 写法 | 含义 | 何时用 |
|------|------|--------|
| **省略字段** | **不限制**(与今日一致) | **默认推荐**:只控 skill 按钮 / 行数据时 |
| 写出非空数组 | 仅允许列出的内置能力(allowlist) | 要限导出、删除等时 |
| 写出 `[]` | 禁止全部上表内置能力 | 极少见;慎用 |
未知字符串:忽略。**禁止**把「省略」解释成全禁。
---
## 3. L2:`data` 与 scope 语法白名单
### 3.1 `data` 形状
```json
"data": {
"tables": {
"customs_timelines": {
"accessible": true,
"columns": {
"visible": ["id", "contract_no", "status"],
"writable": ["status"]
},
"rows": {
"mode": "scope",
"scope": {
"all": [
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
]
}
}
}
}
}
```
| 字段 | 必填 | 说明 |
|------|------|------|
| `data.tables` | 若存在 `data` 则建议有 | `key` = 物理表名 |
| `accessible` | 否 | 缺省 `true`。`false`:宿主可拒绝打开该表 |
| `columns.visible` / `writable` | 否 | 物理列名;省略 = 不额外限制 |
| `rows` | 否 | 见下 |
`writable` 应 ⊆ `visible`;若违规,宿主按交集降级,不 fail-closed。
### 3.2 `rows.mode`(冻)
| 值 | 本期 | 含义 |
|----|------|------|
| `scope` | **实现目标** | 宿主将 `scope` 编译为安全查询约束 |
| `open` | 允许 | 显式不限制行 |
| `delegate` | **仅占位** | 本期未实现时 fail-closed / 提示;**禁止**当 `open` |
缺省:无 `rows` = `open`。
### 3.3 `scope` 布尔结构(冻)
```text
Scope =
| { "all": Predicate[] } // AND,长度 ≥ 1
| { "any": Predicate[] } // OR,长度 ≥ 1
| Predicate // 单个谓词的语法糖
```
**禁止**:嵌套超过一层。
### 3.4 `Predicate` 白名单(冻)
**运算符(宿主必须实现):** `eq` | `ne` | `in` | `not_in` | `gt` | `gte` | `lt` | `lte` | `is_null` | `is_not_null`
**推荐日常只用:** `eq` / `in` / `is_null`(及 `is_not_null`)。
**明确排除:** `like` / `contains` / `sql` / `raw` / `expr`。
**`valueFrom` v1 仅:** `actor.userId`。
`actor.roles` / `actor.attributes.*` **非法** → fail-closed。
`value` 与 `valueFrom` 互斥。标量:`string` | `number` | `boolean` | `null`(与 null 比较优先用 `is_null`)。
### 3.5 与宿主原有过滤的关系
最终行集 = **原有表查询**(搜索、排序、分页、用户 Filter) **AND** policy `scope`。宿主必须把 scope 下推到查询层。
---
## 4. 宿主消费算法
```text
IF manifest.policy?.enabled !== true:
→ L0;END
调用 entrypoint(替换占位符),解析 Snapshot
IF 失败 / 非法 / skill 不匹配 / mode 非法:
→ fail-closed(§6);END
候选 = filter(placements, bind.tables, …)
IF mode == allowlist: 候选 ∩ ids
IF mode == open: 候选不变
渲染 skill 按钮 ← 候选
渲染宿主内置 ← hostCapabilities(省略则不限制)
应用 data.tables[T](若有)
```
拉取时机(建议):进入表、切换表、刷新。短缓存不得跨 `userId`。
---
## 5. 身份与环境
| 项 | 约定 |
|----|------|
| 用户主键 | `JIANGCHANG_USER_ID` |
| 技能解析 | 飞书 / 本地表 / HTTP IAM 等,**契约不规定** |
---
## 6. Fail-closed(启用后必冻)
当且仅当 `policy.enabled === true`:
| 情况 | 宿主行为 |
|------|----------|
| CLI 非 0 / 超时 / 无 JSON | 不渲染该表 skill 写操作与(建议)导出;明确错误;**禁止**回退 L0 全开 |
| `schemaVersion` ≠ 1 / `skill` 不匹配 / `actions.mode` 非法 | 同上 |
| `rows.mode=scope` 但 predicate 非法 | 不返回行或整表不可用;**禁止**忽略 scope |
| `rows.mode=delegate`(未实现) | 提示不支持;**禁止**当 `open` |
---
## 7. 安全边界
1. Snapshot **只**服务宿主 UI 与查询约束。
2. 写 Action / 敏感读的 **domain service 必须再次鉴权**。
3. 禁止「只藏按钮」宣称为完整权限方案。
4. Cron / Agent **不**自动套用本 Snapshot。
---
## 8. 最小示例
模板仓库另有可复制示例:
- [`../assets/examples/skill-policy-snapshot.allowlist.example.json`](../assets/examples/skill-policy-snapshot.allowlist.example.json)
- [`../assets/examples/skill-policy-snapshot.scope.example.json`](../assets/examples/skill-policy-snapshot.scope.example.json)
### 8.1 L0
不写 `policy` 字段。
### 8.2 L1 allowlist(不碰宿主内置)
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1", "displayName": "张三", "roles": ["clerk"] },
"actions": {
"mode": "allowlist",
"ids": ["whoami", "create-record", "register-submit"]
}
}
```
### 8.3 L1 + 限制导出
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1", "roles": ["clerk"] },
"actions": { "mode": "allowlist", "ids": ["whoami"] },
"hostCapabilities": ["refresh", "view"]
}
```
### 8.4 L1 open + L2 scope
```json
{
"schemaVersion": 1,
"skill": "your-skill-slug",
"actor": { "userId": "u1" },
"actions": { "mode": "open" },
"data": {
"tables": {
"orders": {
"rows": {
"mode": "scope",
"scope": {
"all": [
{ "field": "owner_user_id", "op": "eq", "valueFrom": "actor.userId" }
]
}
}
}
}
}
}
```
---
## 9. 刻意延后
| 项 | 备注 |
|----|------|
| `actions.mode: denylist` | 另开 `schemaVersion` |
| `valueFrom: actor.roles` | **明确不做** |
| `visibleWhen` / 嵌套 scope / `like` | L3 或永不做(此处指**行态**策略字段;Action 表单 `inputSchema.visibleWhen` 见 ACTIONS.md,不属本表) |
| `rows.mode: delegate` 宿主实现 | 字段名已占坑 |
| 平台全局 Role UI / cron·agent 套用 Snapshot | 另议 |
---
## 10. 冻结检查清单
- [x] L0 缺省无歧义
- [x] 启用字段仅 `enabled` + `entrypoint` + 可选 `appliesTo`
- [x] `actions.mode` 仅 `allowlist` / `open`
- [x] `hostCapabilities` 省略 = 不限制
- [x] scope 小白名单;`valueFrom` 仅 `actor.userId`
- [x] fail-closed;写路径技能自检

View File

@@ -17,8 +17,11 @@
| 根目录 [`CHANGELOG.md`](../CHANGELOG.md) | 市场更新日志 Tab(版本小节 → `changelog`) |
| [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口与触发摘要 |
| [`CLI.md`](CLI.md) | 共享 Python、命令行入口、参数与短操作契约 |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口(可选能力) |
| [`ACTIONS.md`](ACTIONS.md) | Skill Action、sync/async、任务中心、多入口、按钮中文名 `label` 参考(按入口定位表挂载;先读 `development/PRODUCT_SURFACES.md`) |
| [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 |
| [`POLICY.md`](POLICY.md) | Data Policy:可选身份授权、Snapshot、行 scope(多数技能 L0 不配) |
| [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md) | 数据管理单元格呈现:语义 tone 标色/强调(用途不限;多数技能 P0 不配) |
| [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md) | 业务行生命周期:处理队列的可操作/进行中/终态角色与重做规则(有处理行 Action 时适用) |
| [`../development/`](../development/) | 开发、测试、RPA 深度规范(给人 / 编码助手) |
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`。
按需阅读:短操作优先 `CLI.md`;Action / 任务中心读 `ACTIONS.md`;结构化字段读 `SCHEMA.md`;权限 opt-in 读 `POLICY.md`;格子标色读 `CELL_PRESENTATION.md`;队列处理状态机读 `WORK_ITEM_STATUS.md`。

View File

@@ -1,6 +1,9 @@
# 数据存储与数据管理展示规范
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。
## 职责边界
@@ -10,9 +13,11 @@
- 定义真实 SQLite 表结构
- 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
- 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项
- (可选)按业务规则维护单元格呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
- 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等
@@ -22,11 +27,15 @@
- 读取 SQLite `PRAGMA table_info`
- 根据 schema 渲染数据管理页面
- 根据 `editable` 推导字段是否可创建或编辑
- 在**新增 / 编辑**表单中,若列有 `_jiangchang_columns.description`,在标签下方展示该说明(灰字;空则不显示)
- 在数据管理**表格**中,若表有 `_jiangchang_tables.grid_help` 则在表上方展示可折叠说明;若列有 `_jiangchang_columns.grid_help` 则在列头显示「?」;空/未写则不渲染。**不**把 `description` 当作表格说明
- 打开**新增**表单时,对可创建字段用 SQLite 列 `DEFAULT`(`PRAGMA table_info.dflt_value`)预填;无 DEFAULT 则留空
- 执行通用查询、新增、编辑、删除、导入、导出
- 对字段名、表名、类型和权限进行最终校验
- 不猜测技能业务含义
- 不根据具体技能名称写特殊逻辑
- 可在技能安装/更新成功后静默调用 `python scripts/main.py init-db` 做幂等 ensure(内部即 `init_db()`);失败不影响安装结果
- 若技能启用了单元格呈现(见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)),读取旁路呈现数据并为单元格映射语义样式(`tone`);不猜测业务规则
## 初始化入口
@@ -61,7 +70,7 @@ metadata:
```
- 数据库文件:英文 slug,例如 `your-skill-slug.db`(默认 `{skill-slug}.db`)。
- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`task_logs`、`created_at`。
- 业务表、字段:**英文 snake_case** 物理名,例如 `demo_items`、`created_at`。
- **禁止**用中文作为 SQLite 真实表名或字段名。
## 外部来源只读与本地数据可写
@@ -78,6 +87,25 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除/导入带有领域副作用(自动建目录、加密、级联清理、归一化字段),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete|import` 接管默认按钮。其中 **import** 时用户仍使用宿主「导入数据」面板,确认后由技能写库。详见 [`ACTIONS.md`](ACTIONS.md) 与 `assets/examples/replaces-*.action.example.json`。
## 表与表之间:禁止 SQLite 外键
业务表之间的「属于哪条父记录 / 引用哪条来源」**只存业务 id**(整数或文本标量),**禁止**在 DDL 中写 SQLite `FOREIGN KEY` / `REFERENCES`,也**不要**指望 `PRAGMA foreign_keys = ON` 替你校验或级联。
| 要做的事 | 正确做法 | 禁止 |
|----------|----------|------|
| 子表指向父表 | 子表列如 `case_id INTEGER NOT NULL`;写入前在 repository/service 查父表是否存在 | `FOREIGN KEY (case_id) REFERENCES parent(id)` |
| 父表记「最新一版」 | 父表列如 `latest_draft_id INTEGER NOT NULL DEFAULT 0`;由 service 更新指针 | 同上 |
| 删除 / 覆盖 / 级联清理 | repository/service 内显式 `DELETE` / `UPDATE`;需要时用 Action `steps` 的 `deleteByFk` | 依赖 DB 级联或宿主 CRUD 被 FK 拦住 |
| 数据管理里显示名称 | `_jiangchang_columns.display_type = lookup` + `options_json` 对象(见下文「跨表引用 lookup」) | 把 lookup 当成 DDL 外键 |
**为什么:** 宿主数据管理对业务表执行通用增删改,会直接写 SQLite。一旦 DDL 带外键,删除、迁移 rename 父表、或父表 id 变更时极易出现「本地数据操作失败」且难排查。关联语义属于技能业务,必须在代码里写清。
**迁移注意:** 避免「`ALTER TABLE parent RENAME TO parent_old` → 建新 parent → 删 old」这类会隐性改写子表 FK 指向的套路;优先 `ADD COLUMN` + 拷列,或重建子表且**不带** FK。
`display_metadata_validator` 与 `validate_template_database` 会扫描业务表 DDL;含 `FOREIGN KEY` / `REFERENCES` 时报错(见 `development/CHECKLIST.md` A6-6)。
## 标准数据同步语义(外源 → 本地库)
适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。
@@ -107,13 +135,14 @@ metadata:
}
```
### 同步数据 vs 导出当前数据表 vs 特殊业务报告
### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面
| 能力 | 语义 | 负责方 |
|------|------|--------|
| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` |
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` |
| **HTML 页面报告** | 侧栏可预览的整页 HTML | 写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。
@@ -127,10 +156,117 @@ metadata:
|------|------|------|
| `table_name` | 是 | 真实英文表名 |
| `display_name` | 是 | 用户可见的中文业务名称 |
| `description` | 否 | 表说明 |
| `description` | 否 | 表说明(表单/备注用途);**不**用于表格上方横幅 |
| `grid_help` | 否 | 数据管理表格上方说明;空/未写则不出横幅。与 `description` 独立,禁止互拷(见下节) |
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
| `default_visible_columns_json` | 否 | 数据管理**默认展示列**(见下节);空则全显 |
#### 表级默认排序 `default_sort_json`(宿主支持)
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
`updated_at DESC` → `created_at DESC` → 主键 DESC。
JSON 数组(可多字段,按顺序):
```json
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
```
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(消息时间线):
```python
upsert_table_metadata(
cursor,
table_name="messages",
display_name="消息",
readonly=0,
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
)
```
#### 表级默认展示列 `default_visible_columns_json`(宿主支持)
技能可声明该表在数据管理表格中的**默认展示列**(软默认,不是硬隐藏):
- **已配置**:首次打开(用户尚未改过「列」)只显示名单中的列;其余列仍在「列」面板中,用户可勾回
- **未配置 / 空 / 无效**:显示全部 schema 可见列(与今日行为一致)
- **用户改过「列」或表头「隐藏此列」后**:以本机偏好为准;「恢复默认列布局」后重新回到技能默认(若有)
JSON 数组(物理列名;也可写 `{"column":"col"}` 对象,宿主同样接受):
```json
["stat_type","period","stage","ops_count","timeout_ratio"]
```
规则:
- 列名必须是该表真实列,且 `_jiangchang_columns.visible=1`(硬隐藏列本来就不进表格)
- 与 `visible=0` **不同**:`visible=0` = 列从数据管理 schema /「列」面板移除;本字段只影响默认勾选状态
- 名单为空或全部无效时,宿主 fail-open:全显
- 通过 `upsert_table_metadata(..., default_visible_columns_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(时效统计:默认不展示编号与时间戳列):
```python
upsert_table_metadata(
cursor,
table_name="timeliness_stats",
display_name="时效统计",
readonly=1,
default_visible_columns_json=(
'["stat_type","period","stage","ops_count",'
'"timeout_open","timeout_done","timeout_total","timeout_ratio"]'
),
)
```
#### 表格说明 `grid_help`(宿主支持)
技能可给数据管理**表格**写说明,与新增/编辑表单的 `description` **完全独立**:
| 写在哪 | 宿主做什么 | 空/未写 |
|--------|------------|---------|
| `_jiangchang_tables.grid_help` | 表格上方可折叠横幅(首行摘要;有后续行才出展开) | 不出横幅 |
| `_jiangchang_columns.grid_help` | 列头「?」,点击查看该列说明 | 不出「?」 |
规则:
- **opt-in**:大多数表/列不需要写;不写则行为与今日一致
- **禁止**把 `description` 拷进 `grid_help`,也禁止把表格规则写进 `description`(`description` 只给表单灰字)
- 多行文本时:第一行是摘要,其余行点「展开」后可见
- 通过 `upsert_table_metadata(..., grid_help=...)` / `upsert_column_metadata(..., grid_help=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
- 需已支持该字段的匠厂宿主;更旧版本会忽略未知列,不写则无影响
示例(仅在确有表格规则时写;模板默认业务表不要预填):
```python
upsert_table_metadata(
cursor,
table_name="timeliness_records",
display_name="时效登记",
readonly=1,
grid_help="超时按环节规则判定。\n交单:预计到港日前 3 天须交单",
)
upsert_column_metadata(
cursor,
table_name="timeliness_records",
column_name="submit_date",
display_name="交单日期",
grid_help="预计到港日前 3 天须交单",
editable=0,
)
```
禁止:
@@ -138,6 +274,7 @@ metadata:
- 使用 `_schema_meta` 作为新的正式契约
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
### `_jiangchang_columns` 字段级标准
@@ -145,17 +282,106 @@ metadata:
|------|------|------|
| `table_name` | 是 | 所属物理表 |
| `column_name` | 是 | 真实英文字段名 |
| `display_name` | 是 | 用户可见的中文字段名 |
| `description` | 否 | 字段说明 |
| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 |
| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` |
| `grid_help` | 否 | 数据管理表头「?」;空/未写则不出。与表单 `description` 独立,禁止互拷 |
| `display_order` | 否 | 兼容字段,默认保持 `NULL` |
| `visible` | 是 | 是否在数据管理表格中展示 |
| `visible` | 是 | 是否进入数据管理 schema /「列」面板(`0` = **硬隐藏**,用户无法勾回;默认不展示但可勾回请用表级 `default_visible_columns_json`) |
| `searchable` | 是 | 是否参与搜索 |
| `editable` | 是 | `1` 业务字段允许宿主用于新增和编辑;`0` 系统/只读字段 |
| `display_type` | 否 | 字段渲染类型(如 `text`、`textarea`、`datetime_unix_seconds`) |
| `display_type` | 否 | 字段渲染类型;稳定取值见下节「表单与展示:稳定字段类型」 |
| `options_json` | 否 | 枚举选项 JSON |
这两张表是**数据字典**,不是业务数据;宿主不会把它们当作普通业务表展示。
#### 表单与展示:稳定字段类型(宿主支持,须与 Action 对称)
数据管理「新增 / 编辑」表单与 Skill Action 参数表单应使用**同一套控件语义**(配置入口不同,见 [`ACTIONS.md`](ACTIONS.md))。新技能只使用下表;宿主须两边都实现,避免「Action 有、CRUD 没有」。
**本版稳定支持(须齐):**
| `display_type`(CRUD) | 物理类型建议 | 列表展示 | 新增/编辑控件 | 存库约定 |
|------------------------|--------------|----------|---------------|----------|
| `text`(或缺省) | `TEXT` / 一般标量 | 原文 | 单行文本 | 原文 |
| `textarea` | `TEXT` | 原文(可截断) | 多行文本 | 原文 |
| `json` | `TEXT` | 原文 | 多行文本(JSON 字符串) | 原文;提交时宿主可做基础 JSON 合法性校验 |
| `number` | `INTEGER` / `REAL` / `NUMERIC` | 原文 | **数字输入**(`type=number`) | 数字;无 `display_type` 时,物理类型为 INT/REAL/NUMERIC 也应走数字控件 |
| `boolean` | `INTEGER` 0/1 或 BOOL | 开关语义展示 | 开关 | `0` / `1` |
| (枚举) | 任意 + `options_json` **数组** | 标签映射 | 下拉选择 | 选项 `value` |
| `lookup` | 跨表引用 id;`options_json` **对象**;可选 `multiple` | 单选:标签映射;多选:多标签(如「豆包、元宝」) | 单选下拉;多选见下文 | 单选:业务 id 标量;多选:`TEXT` 逗号分隔 id(非 SQLite 外键) |
| (敏感) | 任意;列 `sensitive=1` | 掩码/隐藏策略由宿主定 | 新建可用密码框;编辑策略与既有宿主一致 | 原文(勿日志明文) |
| `date` | `TEXT` 推荐 `YYYY-MM-DD`,或可安全归一的值 | 日期 | **日期选择**(日) | `YYYY-MM-DD` |
| `datetime_unix_seconds` | `INTEGER` | `YYYY-MM-DD HH:mm:ss` | **日期时间选择**;提交写回 **Unix 秒** | Unix **秒**整数;禁止改成 ISO 字符串 |
| `url` | `TEXT` | 原文(可作链接) | 单行输入 `type=url`(薄层;校验从宽) | 原文 |
| `email` | `TEXT` | 原文 | 单行输入 `type=email`(薄层;校验从宽) | 原文 |
说明灰字与创建默认值见下一小节;与 Action 的对照写在 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。
**明确不在本版稳定范围(勿在新技能宣传为已支持):**
- 文件 / 图片上传
- 复杂 `oneOf` / nested object 表单(任意 JSON 数组当业务多选控件)
- 把业务可编辑时间存成毫秒、ISO 文本或 SQLite `datetime` 文本(审计字段规范见「标准时间字段」)
> **Lookup 多选**已纳入本版契约(见下文 `multiple`),与「任意多选/标签数组」不是同一能力。
`datetime_unix_milliseconds`:仅兼容**列表展示**旧数据;**新技能不要用**;可编辑时间一律 `datetime_unix_seconds`。
#### 新增/编辑表单:字段说明与默认值(宿主支持)
数据管理「新增记录 / 编辑记录」与 Skill Action 弹窗是**两套表单**;CRUD 侧约定如下(交互对齐 Action 的 `inputSchema.description` / `default`,但**配置入口不同**)。
| 能力 | 技能写什么 | 宿主做什么 |
|------|------------|------------|
| 字段说明 | `_jiangchang_columns.description`(可选) | 新增/编辑表单中,在标签下方以灰字展示;空则不显示 |
| 创建默认值 | 物理列 SQLite `DEFAULT`(如 `limit_n INTEGER NOT NULL DEFAULT 0`) | 打开「新增」时,对可创建字段预填该默认值;无 DEFAULT 则留空 |
| 编辑已有行 | — | **不**用 DEFAULT 覆盖已有行数据 |
| 表格列头标题 | `display_name` | 短名;不要把长说明塞进列头文字 |
| 表格列头「?」 | `_jiangchang_columns.grid_help`(可选) | 有内容才显示「?」;**不**回退 `description` |
| 表格上方说明 | `_jiangchang_tables.grid_help`(可选) | 有内容才显示横幅;**不**回退表级 `description` |
规则与禁止:
- **推荐**对易填错、语义特殊的可编辑字段写 `description`(示例链接、`0` 表示不限制等);非强制每个字段都有
- **禁止**把说明硬塞进 `display_name`(如 `评论上限(0不限)`);表头会变脏,也无法与 Action 说明对齐
- 业务语义(如「0 = 不限制」)写在 `description` 里;宿主只展示,**不**解读业务含义
- 第一期**不**新增元数据「表单默认值」列;CRUD 默认值 = DDL `DEFAULT`,与 Action 的 `inputSchema.default` 分开配置
- 同一业务字段若同时出现在 Action 与新增表单,**文案应对齐**(短标题 + 说明),但分别写在 `actions.json` 与 `_jiangchang_columns` / DDL
- 数字类可编辑列推荐 `display_type: "number"`(或依赖 INT/REAL/NUMERIC 推断)并在 DDL 写清 `DEFAULT`(如条数上限默认 `0`)
示例(可编辑列种子 + DDL 默认):
```sql
-- 物理列:创建默认 0
comment_limit INTEGER NOT NULL DEFAULT 0
```
```python
seed_column_metadata_batch(
cursor,
table_name="videos",
columns=(
{
"column_name": "video_url",
"display_name": "视频链接",
"editable": 1,
"display_type": "url",
"description": "填平台视频页链接;需包含可识别的视频路径片段。",
},
{
"column_name": "comment_limit",
"display_name": "采集评论条数",
"editable": 1,
"display_type": "number",
"description": "最多采多少条。填 0 = 不限制;填正整数则按该数量封顶。",
},
),
valid_column_names=physical,
)
```
Action 参数表单的 `title` / `description` / `default` / `display_type` 见 [`ACTIONS.md`](ACTIONS.md);勿与本节 CRUD 配置入口混用。
## editable 与 createable 的关系
技能模板只维护 `editable` 这一份字段权限声明:
@@ -260,15 +486,27 @@ readonly = 0
| 存储格式 | Unix 秒级整数 |
| 展示元数据 `display_type` | `datetime_unix_seconds` |
| 中文名 | `created_at` → **创建时间**;`updated_at` → **更新时间** |
| 用户编辑 | `editable = 0`(系统字段,宿主只展示格式化结果) |
| 用户编辑 | **审计字段** `editable = 0`(系统维护,列表只展示格式化结果,**不**进可编辑表单) |
| 字段顺序 | 位于业务字段末尾:`… → created_at → updated_at` |
| 排序 | **不使用** `display_order`;顺序仅来自 `PRAGMA table_info` |
### 业务可编辑时间 vs 审计时间
| 场景 | `editable` | 列表 | 新增/编辑表单 |
|------|------------|------|----------------|
| `created_at` / `updated_at` 等审计列 | `0` | 格式化为 `YYYY-MM-DD HH:mm:ss` | 不出现(或只读,由宿主既有规则) |
| 业务时间列(预约、截止、活动开始等) | `1` | 同上格式化 | **日期时间控件**;提交写入 Unix **秒**整数 |
宿主对 `display_type = datetime_unix_seconds`:
- **列表**:格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不**改库内原始整数
- **可编辑表单**:提供日期时间选择;保存时写回 Unix **秒**(不要写 ISO 字符串、不要写毫秒)
### 自动生成与维护
- **created_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时可省略,由数据库生成。
- **updated_at**:`INTEGER NOT NULL DEFAULT (unixepoch())`;插入时同样可省略。
- **updated_at 更新**:模板默认表 `task_logs` 通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新。
- **updated_at 更新**:业务表通过 SQLite trigger `{table}_set_updated_at` 在业务字段更新后自动刷新(`init_updated_at_trigger`)。
实现参考:
@@ -276,53 +514,31 @@ readonly = 0
- 中文元数据种子:`scripts/db/display_metadata.py`
- 自检:`scripts/db/display_metadata_validator.py`
宿主只负责把 `display_type = datetime_unix_seconds` 格式化为 `YYYY-MM-DD HH:mm:ss` 展示,**不修改**数据库原始值。
### 系统自动写入表的只读约定
### 模板默认:`task_logs`
`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE),不是供用户在宿主数据管理中手工维护的业务表。
权限设计:
技能内部**系统自动写入**、不供用户在宿主数据管理中手工维护的表,应声明:
| 层级 | 值 | 说明 |
|------|-----|------|
| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 |
| 所有业务字段 `editable` | `0` | 含 `task_type`、`status`、`error_msg`、`result_summary` 等 |
| 技能内部 repository | 正常 INSERT/UPDATE | 元数据权限只约束宿主 UI,不限制技能代码写库 |
| 相关业务字段 `editable` | `0` | 元数据权限只约束宿主 UI,不限制技能代码写库 |
物理定义(权威顺序):
模板**默认不建**此类审计表。过程看 `jiangchang.log`,进度/终态看任务中心,业务结果看业务表。
```sql
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
target_id TEXT,
input_id TEXT,
input_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
```
### 业务行生命周期(处理队列)
对应中文展示(由 `init_db()` 写入元数据表):
若技能对某一业务行做「生成 / 触达 / 私信 / 发布」等有副作用处理,状态字段须遵守 **角色模型**(可操作 / 进行中 / 成功·失败·跳过类终态),**不要**把某一技能的状态名当成全平台唯一枚举。默认只处理可操作态;重做靠人工改回可操作。完整规则见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)。
| 物理字段 | 中文名 | editable |
|----------|--------|----------|
| id | 编号 | 0 |
| task_type | 任务类型 | 0 |
| target_id | 目标编号 | 0 |
| input_id | 输入编号 | 0 |
| input_title | 输入标题 | 0 |
| status | 状态 | 0 |
| error_msg | 错误信息 | 0 |
| result_summary | 结果摘要 | 0 |
| created_at | 创建时间 | 0 |
| updated_at | 更新时间 | 0 |
建议:
表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)。
| 项 | 说明 |
|----|------|
| 状态列 | `options_json` 列出本技能全部业务状态(含进行中与各类终态) |
| 失败原因 | 失败类终态应有用户可读的原因字段(勿写密钥) |
| 可编辑 | 终态/进行中建议可改回可操作,或提供等价 Action |
| `grid_help` | 可一句话说明「仅某某状态可处理」 |
表级 `readonly = 1` 时,宿主数据管理只读展示;技能 repository 仍可写入。
### 通用示例:`demo_items`(测试 fixture 参考)
@@ -354,7 +570,11 @@ CREATE TABLE demo_items (
## 枚举字段 options_json
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
`options_json` 支持两种形态:
### 1)静态枚举(数组)
每项包含 `value` 与 `label`:
```json
[
@@ -366,11 +586,77 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
### 2)跨表引用 lookup(对象,用于关联 id 字段)
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组)。这是**展示与表单选择**契约,**不是** SQLite 外键。
**单选(默认,与既有行为一致):**
```json
{
"ref_table": "platforms",
"value_column": "platform_key",
"label_column": "display_name",
"extra_column": "category",
"filter_column": "enabled",
"filter_value": 1
}
```
**多选(平行扩展,仅多一个开关):**
```json
{
"ref_table": "engines",
"value_column": "id",
"label_column": "display_name",
"filter_column": "enabled",
"filter_value": 1,
"multiple": true
}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `ref_table` | 是 | 同库被引用表名(snake_case 标识符) |
| `value_column` | 是 | 写入当前字段的值列(业务 id / 编号) |
| `label_column` | 是 | 选项主文案列(名称) |
| `extra_column` | 否 | 第三展示列(不必叫「备注」);有值时拼进括号 |
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
| `multiple` | 否 | `true` = 多选 Lookup;省略或 `false` = **单选**(默认) |
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 选项列表。
**存库约定:**
| 模式 | 物理列建议 | 存什么 |
|------|------------|--------|
| 单选 | 整数或文本标量 | 一个业务 id(如 `2`、`douyin`) |
| 多选 | **`TEXT`** | 逗号分隔 id,如 `1,2,5`(与 `$selection.ids` 风格一致);**不要**建联接子表当 UI 多选 |
**统一展示文案(宿主固定一种,技能只配列):**
- 单选默认:`{value} · {label}`,例如 `2 · 通用私信-留资`、`douyin · 抖音`
- 配置了 `extra_column` 且该行有非空值:`{value} · {label}({extra})`
- 未配置 `extra_column`、或额外列为空:不加 `()`
- `label` 为空或与 `value` 相同时:只显示 `value`(可再带额外括号)
- **多选列表格**:按 id 拆开后逐个映射为 label,再用顿号/逗号拼接(如 `豆包、元宝`);过长可截断并提示「+N」
**控件呈现(宿主策略,非第二套契约):**
- 单选:下拉 Select(现状)
- 多选且选项较少(建议 ≤6):勾选列表
- 多选且选项较多:字段内 Chip + 可搜索下拉(**不是**再开一层业务弹窗)
Action 弹窗内联 Lookup 与上表 **同构**(字段名一致,含 `multiple`),见 [`ACTIONS.md`](ACTIONS.md)「跨表引用:Lookup」。
示例:账号表的平台字段 → 平台信息表;凭据表的账号编号 → 账号信息表;私信 Action 选话术 → `message_templates`;抽检 Action 多选引擎 → `engines` + `multiple: true`。
## 幂等初始化标准
- `_jiangchang_tables` 和 `_jiangchang_columns` 必须通过 upsert 初始化
- 多次执行初始化不能重复记录
- 重新初始化可以更新展示名、描述、`visible`、`searchable`、`editable`、`display_type`、`options_json`
- 重新初始化可以更新展示名、描述、`grid_help`、`visible`、`searchable`、`editable`、`display_type`、`options_json`
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
- 元数据不能引用不存在的表或字段
- 新增字段或表时必须同步增加元数据
@@ -416,7 +702,7 @@ seed_column_metadata_batch(
## 中文命名质量
- 使用普通用户能理解的业务用语:`task_logs` → **任务日志**,`error_msg` → **错误信息**。
- 使用普通用户能理解的业务用语:`error_msg` → **错误信息**,`created_at` → **创建时间**。
- 禁止:`Task Logs`、`input编号`、`errormsg`、直接把物理名当 `display_name`。
- 不宜向用户暴露的实现细节(如 `payload_json`、`selector`)应设 `visible = 0` 或给出可理解中文名。
@@ -428,17 +714,6 @@ seed_column_metadata_batch(
4. 初始化脚本必须幂等。
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`。
## 不同业务的 `task_logs` 映射
| 业务场景 | task_type | target_id | input_id |
|----------|-----------|-----------|----------|
| 发布类 | publish | 账号 ID | 内容 ID |
| 工资代发 | disburse | 付款账户 | 批次 ID |
| 对账 | reconcile | 平台标识 | 对账批次 ID |
| 发票验真 | verify | 税务地区 | 发票批次 ID |
业务特有字段优先放入 `result_summary`(JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
## 开发自检
- `init_db()` 后运行 `tests/test_display_metadata.py` 与 `tests/test_data_management_contract.py`。
@@ -448,5 +723,5 @@ seed_column_metadata_batch(
## 模板原则
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。
- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。

View File

@@ -0,0 +1,157 @@
# 业务行生命周期契约(Work Item Lifecycle)
> **状态:正式契约 v1.0。**
> **目标:** 冻结「对某一行做有副作用处理」时的**状态角色与操作权**规则,使队列型技能(生成、触达、私信、发布等)行为一致、可验收。
> **非目标:** 规定某一技能的具体状态码或状态个数;强制所有表都有 status;替代任务中心 / 运行日志;规定 Data Policy 授权。
> **适用范围:** 有「处理业务行」类 Action 的技能。纯配置表、目录表、只读展示表标 N/A。
相关正交轴:[`ACTIONS.md`](ACTIONS.md)(入口与 sync/async)、[`SCHEMA.md`](SCHEMA.md)(表与展示)、[`POLICY.md`](POLICY.md)(谁能点)、任务中心(宿主 Job)。
---
## 0. 为什么需要本契约
若「处理这一行」的按钮对**任意状态**都可点,会出现:
- 已成功的行被重复执行(重复发信、重复生成、覆盖产物)
- 失败行被默认批量静默重试,运营无法区分「未做」与「做过但失败」
- 任务中心显示成功,业务表仍停在初始态(结果未回写)
本契约要求:**默认可自动操作的只有「可操作」角色;其它角色须人工改回可操作后再处理。**
---
## 1. 状态角色(通用,不绑死枚举)
技能自己定义业务状态字符串与中文标签;契约只认**角色**。一个角色可对应**一个或多个**业务状态。
| 角色 | 含义 | 默认可被处理 Action 捞起? |
|------|------|---------------------------|
| **operable(可操作)** | 尚未成功处理、允许自动或行操作启动处理 | **是(默认唯一)** |
| **in_progress(进行中)** | 已抢占,正在处理 | **否**(防双跑) |
| **terminal_success(成功类终态)** | 已按业务定义完成 | **否** |
| **terminal_failure(失败类终态)** | 已尝试且失败;通常带失败原因 | **否** |
| **terminal_skipped(跳过类终态)** | 人工放弃或不处理 | **否** |
### 1.1 举例(仅对照,不是规范枚举)
| 角色 | 技能 A(内容队列)可能叫 | 技能 B(线索触达)可能叫 |
|------|--------------------------|--------------------------|
| operable | 待生成、新建 | 新建、私信未发 |
| in_progress | 生成中 | 触达中 |
| terminal_success | 已生成 | 已私信 |
| terminal_failure | 失败 | 触达失败 |
| terminal_skipped | 已跳过 | 已忽略 |
技能可以有更多业务状态(审核中、待确认、部分成功等):须在需求里写明各自映射到哪一角色,或声明「不属于处理队列、本契约 N/A」。
### 1.2 技能必须声明映射
复制模板后,在 `development/REQUIREMENTS.md`(或等价需求)中写清:
```text
处理队列表:<table>
状态字段:<column>
可操作:<status1>, <status2>, …
进行中:<status…>
成功类终态:…
失败类终态:…(失败原因字段:…)
跳过类终态:…
```
未声明映射的「处理队列」技能,交活 Checklist 不得标 PASS。
---
## 2. 硬规则(技能实现必须遵守)
1. **默认只认 operable**
行操作 / 批量 / 定时 / Agent 触发的「处理」类 Action,pick 与 claim **默认**只包含可操作状态。
不得把成功、失败、跳过、进行中混进默认捞取集合(除非另做**显式命名**的「重试失败」类 Action,且确认文案写清范围——仍推荐不如人工改回可操作简单)。
2. **开始即原子抢占 → in_progress**
在真正产生副作用前,用「仅当当前为 operable 时 UPDATE 为进行中」的方式抢占。抢占失败 → 跳过或返回明确错误(如「当前不是可操作状态」),**禁止**继续副作用。
3. **结束必须回写业务表**
- 成功 → 对应成功类终态,并写入产物路径/摘要等业务字段(若有)
- 失败 → 对应失败类终态,并写入**失败原因**(用户可读;勿塞密钥)
- 禁止「任务中心成功、业务行仍停在可操作/进行中」而不回写
4. **重做靠人工改回 operable**
成功 / 失败 / 跳过 / 卡住的进行中,默认不能再点处理。运营在数据管理把状态改回可操作后,方可再次处理。
5. **任务中心 ≠ 业务状态**
Job「进行中 / 成功 / 失败」是宿主任务态。业务「进行中」必须写在业务表。进度 `emit` 不替代状态回写。
6. **过程与结果分工**(延续既有约定)
过程 → 运行日志 / 任务中心 Activity;业务结果 → 业务表。**不要**靠新建 `task_logs` 一类审计表充当状态机。
---
## 3. Action 与文案约定
- 处理类 Action 的 `description` / `confirmation.message` 应写明:**仅处理「可操作」状态**(用本技能用户能懂的词,如「仅待生成」「仅新建」)。
- 对非可操作行触发时:返回明确失败码与中文说明,提示「请先改回 xxx」。
- 长任务(`async`):除任务中心进度外,业务行应尽快进入进行中,避免界面长时间仍显示可操作导致连点。
正交提醒:
| 轴 | 管什么 | 不管什么 |
|----|--------|----------|
| 本契约 | 什么**状态**能处理 | 谁有权点按钮 |
| Data Policy | 谁有权 | 状态机 |
| executionProfile | sync/async 通道 | 业务终态 |
---
## 4. 数据管理展示建议
- 状态列:`options_json` 枚举完整业务状态(含进行中与各类终态)。
- 失败原因列:失败类终态可见;成功后宜清空或保留末次原因(技能自定,须一致)。
- 终态与进行中的状态字段建议 **可编辑**(或提供「改回可操作」Action),以便人工重做;**不要**只能靠删行重建。
- `grid_help` 可一句话写清:「仅某某状态可处理;其它状态请改回后再操作。」
详见 [`SCHEMA.md`](SCHEMA.md)。
---
## 5. 可观测性(与 ACTIONS / 日志约定对齐)
推荐阶段(名称可自定,须能对应到开始 / 关键节点 / 结束):
1. 已抢占(业务 → 进行中)
2. 处理中关键步骤(可 `emit`)
3. 回写成功或失败
故意失败一条时:业务表应为失败类终态 + 原因;任务中心为失败;日志含 `task_failed` / `error_code=`。
---
## 6. 非目标与常见误区
| 误区 | 正确做法 |
|------|----------|
| 把「新建/成功/失败/跳过」写成全平台唯一枚举 | 只用角色;各技能自定状态名 |
| 默认批量包含失败「方便自动重试」 | 默认不含;重试改回可操作或单独显式 Action |
| 只有任务成功、不改业务状态 | 必须回写业务表 |
| 用进行中替代任务中心 | 两者都要:业务防双跑,任务轨看进度 |
| 所有表都套本契约 | 无「处理行」副作用的表标 N/A |
---
## 7. 验收摘要(完整条目见 CHECKLIST)
有处理队列时至少验证:
1. 成功类终态再点处理 → 拒绝或跳过,不产生重复副作用
2. 可操作行处理中 → 先见进行中,结束后见成功或失败+原因
3. 失败行改回可操作后 → 可以再次处理
4. 任务中心有过程(长任务)且业务表状态与结果一致
---
## 8. 版本
| 版本 | 说明 |
|------|------|
| v1.0 | 首版:角色模型 + 硬规则 + 需求映射 + 与 Action/SCHEMA/任务中心边界 |

View File

@@ -17,7 +17,7 @@
[CmdletBinding()]
param(
[string]$Prefix = "v",
[string]$Message = "正式发布",
[string]$Message,
[switch]$AutoCommit,
[switch]$RequireClean,
[string]$CommitMessage,
@@ -28,6 +28,13 @@ param(
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
# Default tag message is 「正式发布」 from UTF-8 bytes (not a Chinese literal in
# this file). Windows PowerShell 5.1 parses UTF-8 scripts without BOM as the
# system ANSI code page, which would turn a source literal into 「姝e紡鍙戝竷」.
if (-not $PSBoundParameters.ContainsKey("Message") -or [string]::IsNullOrWhiteSpace($Message)) {
$Message = [System.Text.Encoding]::UTF8.GetString([byte[]](0xE6, 0xAD, 0xA3, 0xE5, 0xBC, 0x8F, 0xE5, 0x8F, 0x91, 0xE5, 0xB8, 0x83))
}
function Remove-GitNoise {
param([string[]]$Lines)
return @($Lines | Where-Object {
@@ -35,40 +42,85 @@ function Remove-GitNoise {
})
}
function Convert-GitOutputLines {
param($Output)
return @(
@($Output) | ForEach-Object {
if ($_ -is [System.Management.Automation.ErrorRecord]) { $_.ToString() }
else { "$_" }
}
)
}
# Call git.exe directly. Never wrap with `cmd /c`: on Chinese Windows, cmd
# re-encodes the command line through the OEM code page.
# Also never pass Chinese via `git -m`: Windows PowerShell 5.1 encodes native
# argv using the console code page, so 「正式发布」 becomes 「姝e紡鍙戝竷」
# in the annotated tag. Commit/tag messages go through a UTF-8 file + `-F`.
function Invoke-GitRaw {
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
$prev = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
$output = & git @GitArgs 2>&1
$code = $LASTEXITCODE
} finally {
$ErrorActionPreference = $prev
}
return [pscustomobject]@{
ExitCode = $code
Lines = @(Remove-GitNoise -Lines @(Convert-GitOutputLines -Output $output))
}
}
function Invoke-Git {
param([Parameter(Mandatory = $true)][string]$Args)
Write-Host ">> git $Args" -ForegroundColor DarkGray
& cmd /c "git $Args 2>&1" | Out-Null
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
Write-Host (">> git " + ($GitArgs -join " ")) -ForegroundColor DarkGray
$r = Invoke-GitRaw -GitArgs $GitArgs
if ($r.ExitCode -ne 0) {
throw "git command failed: git $($GitArgs -join ' ')"
}
}
function Invoke-GitWithMessageFile {
param(
[Parameter(Mandatory = $true)][string[]]$GitArgsPrefix,
[Parameter(Mandatory = $true)][string]$Message
)
$msgFile = Join-Path ([System.IO.Path]::GetTempPath()) ("jiangchang-git-msg-" + [guid]::NewGuid().ToString("N") + ".txt")
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($msgFile, ($Message.TrimEnd() + "`n"), $utf8NoBom)
try {
Invoke-Git -GitArgs (@("-c", "i18n.commitEncoding=utf-8") + $GitArgsPrefix + @("-F", $msgFile))
} finally {
Remove-Item -LiteralPath $msgFile -Force -ErrorAction SilentlyContinue
}
}
function Get-GitOutput {
param([Parameter(Mandatory = $true)][string]$Args)
$output = & cmd /c "git $Args 2>&1"
if ($LASTEXITCODE -ne 0) {
throw "git command failed: git $Args"
param([Parameter(Mandatory = $true)][string[]]$GitArgs)
$r = Invoke-GitRaw -GitArgs $GitArgs
if ($r.ExitCode -ne 0) {
throw "git command failed: git $($GitArgs -join ' ')"
}
return @(Remove-GitNoise -Lines @($output))
return @($r.Lines)
}
function Test-Repo {
$output = & cmd /c "git rev-parse --is-inside-work-tree 2>&1"
if ($LASTEXITCODE -ne 0) {
$r = Invoke-GitRaw -GitArgs @("rev-parse", "--is-inside-work-tree")
if ($r.ExitCode -ne 0) {
return $false
}
$clean = Remove-GitNoise -Lines @($output)
return (($clean | Select-Object -First 1) -eq "true")
return (($r.Lines | Select-Object -First 1) -eq "true")
}
function Get-CurrentBranch {
$b = (Get-GitOutput "branch --show-current" | Select-Object -First 1).Trim()
$b = (Get-GitOutput -GitArgs @("branch", "--show-current") | Select-Object -First 1).Trim()
return $b
}
function Get-StatusPorcelain {
$lines = @(Get-GitOutput "status --porcelain")
$lines = @(Get-GitOutput -GitArgs @("status", "--porcelain"))
return $lines
}
@@ -92,7 +144,7 @@ function Parse-SemVerTag {
function Get-NextTag {
param([string]$TagPrefix)
$tags = Get-GitOutput "tag --list"
$tags = Get-GitOutput -GitArgs @("tag", "--list")
$parsed = @()
foreach ($t in $tags) {
@@ -181,7 +233,7 @@ function Ensure-CleanOrAutoCommit {
if ($NeedClean) {
Write-Host "Working tree is not clean and -RequireClean is enabled." -ForegroundColor Yellow
Remove-GitNoise -Lines @(& cmd /c "git status --short 2>&1") | ForEach-Object { Write-Host $_ }
(Invoke-GitRaw -GitArgs @("status", "--short")).Lines | ForEach-Object { Write-Host $_ }
throw "Abort: dirty working tree."
}
@@ -200,8 +252,8 @@ function Ensure-CleanOrAutoCommit {
return
}
Invoke-Git "add -A"
Invoke-Git "commit -m `"$commitMsg`""
Invoke-Git -GitArgs @("add", "-A")
Invoke-GitWithMessageFile -GitArgsPrefix @("commit") -Message $commitMsg
}
try {
@@ -223,10 +275,10 @@ try {
if ($DryRun) {
Write-Host "[DryRun] Would run: git fetch --tags --prune origin" -ForegroundColor Yellow
} else {
Invoke-Git "fetch --tags --prune origin"
Invoke-Git -GitArgs @("fetch", "--tags", "--prune", "origin")
}
$skillRoot = (Get-GitOutput "rev-parse --show-toplevel" | Select-Object -First 1).Trim()
$skillRoot = (Get-GitOutput -GitArgs @("rev-parse", "--show-toplevel") | Select-Object -First 1).Trim()
if (Test-Path -LiteralPath (Join-Path $skillRoot "SKILL.md")) {
Assert-SkillReleasePackagingSources -SkillRoot $skillRoot
}
@@ -235,8 +287,8 @@ try {
Ensure-CleanOrAutoCommit -DoAutoCommit:$AutoCommit -NeedClean:$RequireClean -IsDryRun:$DryRun -Msg $CommitMessage
$null = Remove-GitNoise -Lines @(& cmd /c 'git rev-parse --abbrev-ref --symbolic-full-name "@{u}" 2>&1')
$hasUpstream = ($LASTEXITCODE -eq 0)
$upstream = Invoke-GitRaw -GitArgs @("rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}")
$hasUpstream = ($upstream.ExitCode -eq 0)
if ($DryRun) {
if ($hasUpstream) {
@@ -246,29 +298,29 @@ try {
}
} else {
if ($hasUpstream) {
Invoke-Git "push"
Invoke-Git -GitArgs @("push")
} else {
Invoke-Git "push -u origin $branch"
Invoke-Git -GitArgs @("push", "-u", "origin", $branch)
}
}
$nextTag = Get-NextTag -TagPrefix $Prefix
Write-Host "Next tag: $nextTag" -ForegroundColor Green
$existing = @(Get-GitOutput "tag --list `"$nextTag`"")
$existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag))
if ($existing.Length -gt 0) {
throw "Tag already exists: $nextTag"
}
if ($DryRun) {
Write-Host "[DryRun] Would run: git tag -a $nextTag -m `"$Message`"" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git tag -a $nextTag -F <utf8-msg-file> ($Message)" -ForegroundColor Yellow
Write-Host "[DryRun] Would run: git push origin $nextTag" -ForegroundColor Yellow
Write-Host "=== DryRun Complete ===" -ForegroundColor Cyan
exit 0
}
Invoke-Git "tag -a $nextTag -m `"$Message`""
Invoke-Git "push origin $nextTag"
Invoke-GitWithMessageFile -GitArgsPrefix @("tag", "-a", $nextTag) -Message $Message
Invoke-Git -GitArgs @("push", "origin", $nextTag)
Write-Host "Release success: $nextTag" -ForegroundColor Green
Write-Host "=== Release Script Done ===" -ForegroundColor Cyan

View File

@@ -13,8 +13,6 @@ from service.task_service import (
cmd_config_path,
cmd_health,
cmd_init_db,
cmd_log_get,
cmd_logs,
cmd_run,
cmd_version,
)
@@ -58,8 +56,6 @@ def _handle_run(args: argparse.Namespace) -> int:
def _print_full_usage() -> None:
print("通用业务技能模板(main.py)可用命令:")
print(" python main.py run [target [input_id]] [-t target] [-i input_id]")
print(" python main.py logs [--limit N] [--status s] [--task-type t] [--target-id tid]")
print(" python main.py log-get <log_id>")
print(" python main.py health")
print(" python main.py config-path")
print(" python main.py version")
@@ -69,7 +65,7 @@ def _print_full_usage() -> None:
def build_parser() -> ZhArgumentParser:
p = ZhArgumentParser(
prog="main.py",
description="通用业务技能模板:任务执行命令骨架、日志查询、健康检查、版本输出。",
description="通用业务技能模板:任务执行命令骨架、健康检查、版本输出。",
formatter_class=argparse.RawDescriptionHelpFormatter,
)
sub = p.add_subparsers(dest="cmd", required=True, parser_class=ZhArgumentParser)
@@ -80,19 +76,6 @@ def build_parser() -> ZhArgumentParser:
sp.add_argument("run_tail", nargs="*", metavar="位置参数")
sp.set_defaults(handler=_handle_run)
sp = sub.add_parser("logs", help="查看任务日志")
sp.add_argument("--limit", type=int, default=10)
sp.add_argument("--status", default=None)
sp.add_argument("--task-type", default=None, dest="task_type")
sp.add_argument("--target-id", default=None, dest="target_id")
sp.set_defaults(handler=lambda a: cmd_logs(
limit=a.limit, status=a.status, task_type=a.task_type, target_id=a.target_id
))
sp = sub.add_parser("log-get", help="按 log_id 查看单条任务日志(JSON)")
sp.add_argument("log_id")
sp.set_defaults(handler=lambda a: cmd_log_get(a.log_id))
sp = sub.add_parser("health", help="健康检查")
sp.set_defaults(handler=lambda _a: cmd_health())
@@ -116,7 +99,7 @@ def main(argv: Optional[List[str]] = None) -> int:
_print_full_usage()
return 1
if len(argv) == 2 and argv[0] not in {
"run", "logs", "log-get", "health", "config-path", "version", "init-db",
"run", "health", "config-path", "version", "init-db",
"-h", "--help",
}:
return cmd_run(target=argv[0], input_id=argv[1])

View File

@@ -1,11 +1,13 @@
"""SQLite 连接与任务日志表迁移模板。"""
"""SQLite 连接与幂等初始化模板。
模板默认只建数据管理元数据表。业务表由复制后的技能自行 CREATE,并同步展示元数据。
"""
from __future__ import annotations
import sqlite3
from db.display_metadata import init_display_metadata
from db.timestamp_columns import init_task_logs_timestamp_maintenance
from util.runtime_paths import get_db_path
@@ -17,23 +19,6 @@ def init_db() -> None:
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE IF NOT EXISTS task_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
task_type TEXT NOT NULL,
target_id TEXT,
input_id TEXT,
input_title TEXT,
status TEXT NOT NULL,
error_msg TEXT,
result_summary TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
)
init_task_logs_timestamp_maintenance(cur)
init_display_metadata(cur)
conn.commit()
finally:

View File

@@ -13,7 +13,6 @@ INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns
LEGACY_METADATA_TABLES = frozenset({"_schema_meta"})
METADATA_TABLES = INTERNAL_METADATA_TABLES | LEGACY_METADATA_TABLES
TASK_LOGS_TABLE = "task_logs"
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
@@ -22,9 +21,12 @@ CREATE TABLE IF NOT EXISTS _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
grid_help TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT,
default_visible_columns_json TEXT
)
"""
@@ -34,6 +36,7 @@ CREATE TABLE IF NOT EXISTS _jiangchang_columns (
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
grid_help TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
@@ -44,61 +47,87 @@ CREATE TABLE IF NOT EXISTS _jiangchang_columns (
)
"""
TASK_LOGS_TABLE_METADATA = {
"table_name": TASK_LOGS_TABLE,
"display_name": "任务日志",
"description": "记录技能任务的执行状态和结果(系统自动写入,宿主数据管理只读展示)",
"visible": 1,
"readonly": 1,
}
def _pragma_column_names(cursor, table_name: str) -> set[str]:
cursor.execute(f"PRAGMA table_info({table_name!r})")
return {str(row[1]) for row in cursor.fetchall()}
TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 0},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间(Unix 秒级时间戳,数据库自动生成)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"description": "记录最后更新时间(Unix 秒级时间戳,自动维护)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": DATETIME_UNIX_SECONDS,
},
)
def _ensure_jiangchang_tables_columns(cursor) -> None:
"""旧库补列:CREATE IF NOT EXISTS 不会给已有表加新字段。"""
cols = _pragma_column_names(cursor, "_jiangchang_tables")
if "default_sort_json" not in cols:
cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN default_sort_json TEXT")
if "default_visible_columns_json" not in cols:
cursor.execute(
"ALTER TABLE _jiangchang_tables ADD COLUMN default_visible_columns_json TEXT"
)
if "grid_help" not in cols:
cursor.execute("ALTER TABLE _jiangchang_tables ADD COLUMN grid_help TEXT")
def _ensure_jiangchang_columns_columns(cursor) -> None:
cols = _pragma_column_names(cursor, "_jiangchang_columns")
if "grid_help" not in cols:
cursor.execute("ALTER TABLE _jiangchang_columns ADD COLUMN grid_help TEXT")
def init_display_metadata_tables(cursor) -> None:
cursor.execute(_JIANGCHANG_TABLES_DDL)
cursor.execute(_JIANGCHANG_COLUMNS_DDL)
_ensure_jiangchang_tables_columns(cursor)
_ensure_jiangchang_columns_columns(cursor)
def upsert_table_metadata(cursor, *, table_name: str, display_name: str, description: str | None = None, visible: int = 1, readonly: int = 0) -> None:
def upsert_table_metadata(
cursor,
*,
table_name: str,
display_name: str,
description: str | None = None,
grid_help: str | None = None,
visible: int = 1,
readonly: int = 0,
default_sort_json: str | None = None,
default_visible_columns_json: str | None = None,
) -> None:
"""写入表级元数据。
default_sort_json: 宿主数据管理默认排序,JSON 数组,例如
'[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]'
为空则宿主回退到 updated_at → created_at → 主键。
default_visible_columns_json: 宿主数据管理默认展示列,JSON 字符串数组,例如
'["stat_type","period","stage"]'
为空则全显;用户改过「列」后以本机偏好为准(软默认,区别于 visible=0 硬隐藏)。
grid_help: 数据管理表格上方说明;空则不出横幅。与表单用的 description 独立,禁止互拷。
"""
cursor.execute(
"""
INSERT INTO _jiangchang_tables (table_name, display_name, description, visible, readonly)
VALUES (?, ?, ?, ?, ?)
INSERT INTO _jiangchang_tables (
table_name, display_name, description, grid_help, visible, readonly,
default_sort_json, default_visible_columns_json
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(table_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
grid_help = excluded.grid_help,
visible = excluded.visible,
readonly = excluded.readonly
readonly = excluded.readonly,
default_sort_json = excluded.default_sort_json,
default_visible_columns_json = excluded.default_visible_columns_json
""",
(table_name, display_name, description, visible, readonly),
(
table_name,
display_name,
description,
grid_help,
visible,
readonly,
default_sort_json,
default_visible_columns_json,
),
)
@@ -109,6 +138,7 @@ def upsert_column_metadata(
column_name: str,
display_name: str,
description: str | None = None,
grid_help: str | None = None,
visible: int = 1,
searchable: int = 1,
editable: int = 1,
@@ -116,16 +146,18 @@ def upsert_column_metadata(
options_json: str | None = None,
) -> None:
# display_order 保留为 NULL:宿主按 PRAGMA table_info cid 展示字段,不读 display_order。
# grid_help: 表头「?」;空则不出。与表单用的 description 独立,禁止互拷。
cursor.execute(
"""
INSERT INTO _jiangchang_columns (
table_name, column_name, display_name, description,
table_name, column_name, display_name, description, grid_help,
display_order, visible, searchable, editable, display_type, options_json
)
VALUES (?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?)
ON CONFLICT(table_name, column_name) DO UPDATE SET
display_name = excluded.display_name,
description = excluded.description,
grid_help = excluded.grid_help,
display_order = NULL,
visible = excluded.visible,
searchable = excluded.searchable,
@@ -138,6 +170,7 @@ def upsert_column_metadata(
column_name,
display_name,
description,
grid_help,
visible,
searchable,
editable,
@@ -147,24 +180,6 @@ def upsert_column_metadata(
)
def seed_task_logs_display_metadata(cursor) -> None:
meta = TASK_LOGS_TABLE_METADATA
upsert_table_metadata(
cursor,
table_name=str(meta["table_name"]),
display_name=str(meta["display_name"]),
description=str(meta["description"]) if meta.get("description") else None,
visible=int(meta["visible"]),
readonly=int(meta["readonly"]),
)
seed_column_metadata_batch(
cursor,
table_name=TASK_LOGS_TABLE,
columns=TASK_LOGS_COLUMN_METADATA,
valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA],
)
def seed_column_metadata_batch(
cursor,
*,
@@ -180,6 +195,7 @@ def seed_column_metadata_batch(
column_name=str(column["column_name"]),
display_name=str(column["display_name"]),
description=str(column["description"]) if column.get("description") else None,
grid_help=str(column["grid_help"]) if column.get("grid_help") else None,
visible=int(column.get("visible", 1)),
searchable=int(column.get("searchable", 1)),
editable=int(column.get("editable", 1)),
@@ -259,7 +275,6 @@ def table_allows_manual_create(cursor, table_name: str) -> bool:
def init_display_metadata(cursor) -> None:
init_display_metadata_tables(cursor)
seed_task_logs_display_metadata(cursor)
def iter_user_tables(cursor) -> Iterable[str]:

View File

@@ -12,8 +12,6 @@ from db.display_metadata import (
DATETIME_UNIX_SECONDS,
METADATA_TABLES,
STANDARD_TIMESTAMP_COLUMNS,
TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE,
iter_user_tables,
list_editable_business_columns,
list_physical_column_names,
@@ -32,6 +30,7 @@ _ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
)
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_FK_DDL_PATTERN = re.compile(r"\bFOREIGN\s+KEY\b|\bREFERENCES\b", re.I)
@dataclass
@@ -196,7 +195,7 @@ def _default_uses_unixepoch(default_value: object) -> bool:
def validate_options_json(options_json: str | None) -> tuple[list[str], list[str]]:
"""校验枚举 options_json 可被解析为 [{value, label}, ...] 结构。"""
"""校验 options_json:静态枚举数组,或 lookup 对象。"""
errors: list[str] = []
warnings: list[str] = []
if options_json is None or str(options_json).strip() == "":
@@ -206,8 +205,26 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
except json.JSONDecodeError as exc:
errors.append(f"options_json 不是合法 JSON:{exc}")
return errors, warnings
if isinstance(parsed, dict):
for key in ("ref_table", "value_column", "label_column"):
if not str(parsed.get(key) or "").strip():
errors.append(f"options_json lookup 必须包含非空 {key}")
ident = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
for key in ("ref_table", "value_column", "label_column", "filter_column"):
raw = parsed.get(key)
if raw is None or raw == "":
continue
if not ident.match(str(raw)):
errors.append(f"options_json.{key} 必须是安全标识符,当前为 {raw!r}")
if parsed.get("filter_column") not in (None, "") and "filter_value" not in parsed:
warnings.append("options_json lookup 声明了 filter_column 但未提供 filter_value")
if "multiple" in parsed and not isinstance(parsed.get("multiple"), bool):
errors.append("options_json lookup.multiple 必须是布尔值")
return errors, warnings
if not isinstance(parsed, list):
errors.append("options_json 必须是 JSON 数组")
errors.append("options_json 必须是 JSON 数组(静态枚举)或对象(lookup)")
return errors, warnings
for index, item in enumerate(parsed):
if not isinstance(item, dict):
@@ -398,6 +415,27 @@ def _table_exists(cursor, table_name: str) -> bool:
return cursor.fetchone() is not None
def validate_no_sqlite_foreign_keys(cursor) -> tuple[list[str], list[str]]:
"""业务表 DDL 不得含 FOREIGN KEY / REFERENCES(关联走 repository/service)。"""
errors: list[str] = []
cursor.execute(
"""
SELECT name, sql FROM sqlite_master
WHERE type = 'table' AND name NOT LIKE 'sqlite_%'
"""
)
for table_name, sql in cursor.fetchall():
if table_name in METADATA_TABLES:
continue
ddl = str(sql or "")
if ddl and _FK_DDL_PATTERN.search(ddl):
errors.append(
f"业务表 {table_name!r} 的 DDL 含 SQLite FOREIGN KEY/REFERENCES;"
"请改为业务 id 列 + repository/service 逻辑关联(见 references/SCHEMA.md)"
)
return errors, []
def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: bool = True) -> ValidationReport:
report = ValidationReport()
cur = conn.cursor()
@@ -411,6 +449,9 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
if not has_tables_meta or not has_columns_meta:
return report
fk_errors, _ = validate_no_sqlite_foreign_keys(cur)
report.errors.extend(fk_errors)
for table_name in iter_user_tables(cur):
cur.execute(
"SELECT display_name FROM _jiangchang_tables WHERE table_name = ?",
@@ -500,11 +541,5 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
report = validate_display_metadata(conn)
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
if physical != expected:
report.errors.append(
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
)
return report
"""模板自检入口:校验展示元数据完整性,并扫描业务表 DDL 是否含 SQLite 外键。"""
return validate_display_metadata(conn)

View File

@@ -1,86 +0,0 @@
"""task_logs 表读写模板。
通用业务日志仓储:记录每次任务执行的 task_type / target / input / 状态 / 结果摘要。
不假设任何特定业务(发布、对账、审核等都可复用)。
"""
from __future__ import annotations
from typing import Any, List, Optional, Tuple
from db.connection import get_conn, init_db
def save_task_log(
task_type: str,
target_id: Optional[str] = None,
input_id: Optional[str] = None,
input_title: Optional[str] = None,
status: str = "success",
error_msg: Optional[str] = None,
result_summary: Optional[str] = None,
) -> int:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
INSERT INTO task_logs
(task_type, target_id, input_id, input_title, status, error_msg, result_summary)
VALUES (?, ?, ?, ?, ?, ?, ?)
""",
(task_type, target_id, input_id, input_title or "", status, error_msg, result_summary),
)
new_id = int(cur.lastrowid)
conn.commit()
finally:
conn.close()
return new_id
def list_task_logs(
limit: int,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> List[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
sql = (
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE 1=1 "
)
params: List[Any] = []
if status:
sql += "AND status = ? "
params.append(status)
if task_type:
sql += "AND task_type = ? "
params.append(task_type)
if target_id:
sql += "AND target_id = ? "
params.append(target_id)
sql += "ORDER BY created_at DESC, id DESC LIMIT ?"
params.append(int(limit))
cur.execute(sql, tuple(params))
return list(cur.fetchall())
finally:
conn.close()
def get_task_log_by_id(log_id: int) -> Optional[Tuple[Any, ...]]:
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"SELECT id, task_type, target_id, input_id, input_title, status, error_msg, result_summary, "
"created_at, updated_at FROM task_logs WHERE id = ?",
(int(log_id),),
)
return cur.fetchone()
finally:
conn.close()

View File

@@ -1,30 +1,44 @@
"""标准时间字段:Unix 秒级时间戳、默认值与 updated_at 自动维护。"""
"""标准时间字段:Unix 秒级时间戳、默认值与 updated_at 自动维护。
业务表若包含 created_at / updated_at,在 CREATE TABLE 后调用
``init_updated_at_trigger(cursor, table_name)``。模板默认不建业务表。
"""
from __future__ import annotations
STANDARD_TIMESTAMP_COLUMNS = frozenset({"created_at", "updated_at"})
DATETIME_UNIX_SECONDS = "datetime_unix_seconds"
TASK_LOGS_UPDATED_AT_TRIGGER = "task_logs_set_updated_at"
_TASK_LOGS_UPDATED_AT_TRIGGER_DDL = f"""
CREATE TRIGGER IF NOT EXISTS {TASK_LOGS_UPDATED_AT_TRIGGER}
AFTER UPDATE ON task_logs
def updated_at_trigger_name(table_name: str) -> str:
return f"{table_name}_set_updated_at"
def _quote_identifier(name: str) -> str:
return '"' + name.replace('"', '""') + '"'
def init_updated_at_trigger(cursor, table_name: str) -> None:
"""幂等创建 {table}_set_updated_at:业务字段 UPDATE 后刷新 updated_at。"""
trigger = updated_at_trigger_name(table_name)
quoted = _quote_identifier(table_name)
quoted_trigger = _quote_identifier(trigger)
cursor.execute(
f"""
CREATE TRIGGER IF NOT EXISTS {quoted_trigger}
AFTER UPDATE ON {quoted}
FOR EACH ROW
BEGIN
UPDATE task_logs
UPDATE {quoted}
SET updated_at = unixepoch()
WHERE id = NEW.id AND updated_at = OLD.updated_at;
END
"""
def init_task_logs_timestamp_maintenance(cursor) -> None:
"""幂等创建 task_logs.updated_at 自动维护 trigger。"""
cursor.execute(_TASK_LOGS_UPDATED_AT_TRIGGER_DDL)
)
def has_updated_at_trigger(cursor, table_name: str) -> bool:
trigger_name = f"{table_name}_set_updated_at"
trigger_name = updated_at_trigger_name(table_name)
cursor.execute(
"""
SELECT 1 FROM sqlite_master

View File

@@ -0,0 +1,77 @@
"""技能 HTML 报告页:写入 `{skill_data}/html/*.html`(宿主扫描预览)。
契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」):
- 目录:仅一层 `html/*.html`(非递归)
- 重新生成 Action id 必须为 `render-html`(宿主硬编码)
- 成功后建议 `steps: refreshCatalog` 以重扫侧栏
- 本模块只提供路径/模板/原子写;业务 KPI/表格由各技能自行填充
"""
from __future__ import annotations
import os
import re
from typing import Mapping
from util.logging_config import get_skill_logger
from util.runtime_paths import get_html_dir, get_skill_root
# Host Data Management looks up this exact Skill Action id for「重新生成」.
HOST_HTML_RENDER_ACTION_ID = "render-html"
_UNSAFE_NAME = re.compile(r"[\\/]|^\.\.|\.\.$")
def _log():
try:
return get_skill_logger()
except RuntimeError:
return None
def assert_html_filename(filename: str) -> str:
name = (filename or "").strip()
if not name.lower().endswith(".html"):
raise ValueError(f"html filename must end with .html: {filename!r}")
if _UNSAFE_NAME.search(name) or "/" in name or "\\" in name:
raise ValueError(f"html filename must be a basename only: {filename!r}")
if len(name) > 200:
raise ValueError(f"html filename too long: {filename!r}")
return name
def html_page_path(filename: str, *, create_dir: bool = True) -> str:
return os.path.join(get_html_dir(create=create_dir), assert_html_filename(filename))
def load_html_template(relative_under_skill_root: str) -> str:
"""Load UTF-8 template from skill root (e.g. ``assets/html/example-report.html``)."""
rel = (relative_under_skill_root or "").strip().replace("\\", "/")
if not rel or rel.startswith("/") or ".." in rel.split("/"):
raise ValueError(f"invalid template path: {relative_under_skill_root!r}")
path = os.path.join(get_skill_root(), *rel.split("/"))
with open(path, encoding="utf-8") as f:
return f.read()
def render_html_placeholders(template: str, mapping: Mapping[str, str]) -> str:
"""Replace ``{{KEY}}`` tokens. Unknown keys are left as-is."""
page = template
for key, value in mapping.items():
page = page.replace("{{" + str(key) + "}}", str(value))
return page
def write_html_page(filename: str, html: str) -> str:
"""Atomically write UTF-8 HTML under ``html/``. Returns absolute path."""
dest = html_page_path(filename, create_dir=True)
tmp = dest + ".tmp"
text = html if isinstance(html, str) else str(html)
with open(tmp, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
os.replace(tmp, dest)
log = _log()
if log:
log.info("html_page_written path=%s bytes=%s", dest, len(text.encode("utf-8")))
return dest

View File

@@ -10,7 +10,7 @@ def build_video_info(
compose_context_before: Optional[dict[str, Any]] = None,
compose_context_after: Optional[dict[str, Any]] = None,
) -> dict[str, Any]:
"""将 RpaVideoSession.summary() 转为 task log / CLI 可用的 video 块。"""
"""将 RpaVideoSession.summary() 转为 finish() / CLI 可用的 video 块。"""
return {
"enabled": video_summary.get("enabled", False),
"path": video_summary.get("path"),
@@ -31,7 +31,7 @@ def merge_video_into_result_summary(
payload: dict[str, Any],
video_info: Optional[dict[str, Any]],
) -> dict[str, Any]:
"""把 video artifact 字段写入 result_summary JSON 对象。"""
"""把 video artifact 字段写入 finish() 结果对象(video / video_path 等)。"""
if not video_info:
return payload
payload = dict(payload)

View File

@@ -1,6 +1,7 @@
"""任务编排、日志查询模板。
"""任务编排模板。
编排层:job_context / emit / checkpoint / finish、鉴权、任务日志与错误转换。
编排层:job_context / emit / checkpoint / finish、鉴权与错误转换。
过程看统一文件日志;进度/终态看任务中心;业务结果写入业务表(有才建)。
领域业务请放在 ``service/<domain>_service.py``,由本模块调用;多入口(CLI/Action/cron)复用同一内核。
复制后在 cmd_run(或 cmd_sync_*)中接线到真实 domain service,勿按 toolbar/cron/agent 分叉业务。
"""
@@ -11,12 +12,11 @@ import asyncio
import json
import os
import uuid
from typing import Any, Dict, Optional
from typing import Any, Optional
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context
from db import task_logs_repository as tlr
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from service.entitlement_service import check_entitlement
from service.task_run_support import (
@@ -27,7 +27,6 @@ from service.task_run_support import (
from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.logging_config import get_skill_logger, setup_skill_logging
from util.runtime_paths import get_db_path, get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso
from db.connection import init_db
@@ -70,7 +69,7 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
with job_context(skill=SKILL_SLUG):
log.info(
"task_start task_type=%s target_id=%s input_id=%s",
"task_start task_type=%s target_id=%s input_id=%s stage=run",
task_type,
target,
input_id,
@@ -79,23 +78,11 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
try:
ok, reason = check_entitlement(SKILL_SLUG)
if not ok:
log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
tlr.save_task_log(
task_type=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg=reason,
result_summary=json.dumps({"stage": "auth", "error": reason}, ensure_ascii=False),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s stage=auth status=failed",
log.warning(
"entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED",
task_type,
target,
input_id,
)
emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth")
finish(
status="failed",
message=reason,
@@ -103,12 +90,19 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
error_code="ENTITLEMENT_DENIED",
stage="auth",
)
print(f"❌ {reason}")
print(f"ERROR:ENTITLEMENT_DENIED {reason}")
return 1
log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id)
log.info("entitlement_ok task_type=%s stage=auth", task_type)
log.info("rpa_start stage=demo_prepare task_type=%s", task_type)
log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id)
rc, video_info = asyncio.run(_run_template_demo(target, input_id))
log.info("task_run_demo_done target_id=%s input_id=%s status=failed", target, input_id)
log.info("rpa_done stage=demo_prepare status=ok")
log.info(
"task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed",
target,
input_id,
)
_print_video_summary(video_info)
summary_payload = merge_video_into_result_summary(
@@ -119,31 +113,19 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
},
video_info,
)
tlr.save_task_log(
task_type=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="模板仓库未实现真实业务",
result_summary=json.dumps(summary_payload, ensure_ascii=False),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s status=failed",
task_type,
target,
input_id,
)
log.warning(
"task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB",
task_type,
)
finish(
status="failed",
message="模板仓库未实现真实业务",
skill=SKILL_SLUG,
template_demo=True,
target=target,
input_id=input_id,
error_code="TEMPLATE_STUB",
**summary_payload,
)
print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
print("ERROR:TEMPLATE_STUB 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
return rc
except JobStopped:
raise
@@ -155,32 +137,6 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
input_id,
)
emit("任务失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="run")
try:
tlr.save_task_log(
task_type=task_type,
target_id=target,
input_id=input_id,
input_title="模板示例任务",
status="failed",
error_msg="任务执行异常,详见统一日志",
result_summary=json.dumps(
{"stage": "run", "error": "unexpected_exception"},
ensure_ascii=False,
),
)
log.info(
"task_log_saved task_type=%s target_id=%s input_id=%s stage=run status=failed",
task_type,
target,
input_id,
)
except Exception:
log.exception(
"task_log_save_failed task_type=%s target_id=%s input_id=%s",
task_type,
target,
input_id,
)
finish(
status="failed",
message="任务执行异常,详见统一日志",
@@ -190,67 +146,6 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
return 1
def cmd_logs(
limit: int = 10,
status: Optional[str] = None,
task_type: Optional[str] = None,
target_id: Optional[str] = None,
) -> int:
if limit <= 0:
limit = 10
rows = tlr.list_task_logs(limit, status, task_type, target_id)
if not rows:
print("暂无任务日志")
return 0
sep_line = "_" * 39
for idx, r in enumerate(rows):
rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = r
print(f"id:{rid}")
print(f"task_type:{ttype or ''}")
print(f"target_id:{tid or ''}")
print(f"input_id:{iid or ''}")
print(f"input_title:{ititle or ''}")
print(f"status:{st or ''}")
print(f"error_msg:{err or ''}")
print(f"result_summary:{rsum or ''}")
print(f"created_at:{unix_to_iso(cat) or str(cat or '')}")
print(f"updated_at:{unix_to_iso(uat) or str(uat or '')}")
if idx != len(rows) - 1:
print(sep_line)
print()
return 0
def cmd_log_get(log_id: str) -> int:
if not str(log_id).isdigit():
print("❌ log_id 必须是数字")
return 1
row = tlr.get_task_log_by_id(int(log_id))
if not row:
print("❌ 没有这条任务日志")
return 1
rid, ttype, tid, iid, ititle, st, err, rsum, cat, uat = row
print(
json.dumps(
{
"id": int(rid),
"task_type": ttype,
"target_id": tid,
"input_id": iid,
"input_title": ititle,
"status": st,
"error_msg": err,
"result_summary": rsum,
"created_at": unix_to_iso(cat),
"updated_at": unix_to_iso(uat),
},
ensure_ascii=False,
)
)
return 0
def cmd_config_path() -> int:
example_path = os.path.join(get_skill_root(), ".env.example")
env_path = config.get_env_file_path() or ""

View File

@@ -1,6 +1,6 @@
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
SKILL_SLUG = "your-skill-slug"
SKILL_VERSION = "1.0.57"
SKILL_VERSION = "1.0.96"
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
PLATFORM_KIT_MIN_VERSION = "1.2.2"
PLATFORM_KIT_MIN_VERSION = "1.2.11"

View File

@@ -10,18 +10,22 @@ from util.constants import SKILL_SLUG
_SCRIPTS_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STANDARD_SUBDIR_DOWNLOADS = "downloads"
STANDARD_SUBDIR_IMPORTS = "imports"
STANDARD_SUBDIR_EXPORTS = "exports"
# 用户可见的上行/下行目录(数据管理「输入/输出」文件浏览器)
STANDARD_SUBDIR_INPUT = "input"
STANDARD_SUBDIR_OUTPUT = "output"
# 宿主扫描的 HTML 报告页(数据管理侧栏 htmlPages,非 input/output 文件浏览器)
STANDARD_SUBDIR_HTML = "html"
# 内部/可选目录(不上侧栏)
STANDARD_SUBDIR_UPLOADS = "uploads"
STANDARD_SUBDIR_CACHE = "cache"
STANDARD_SUBDIR_TEMP = "temp"
STANDARD_SUBDIR_RPA_ARTIFACTS = "rpa-artifacts"
STANDARD_SUBDIR_VIDEOS = "videos"
CONFIG_KEY_DOWNLOAD_DIR = "SKILL_DOWNLOAD_DIR"
CONFIG_KEY_IMPORT_DIR = "SKILL_IMPORT_DIR"
CONFIG_KEY_EXPORT_DIR = "SKILL_EXPORT_DIR"
CONFIG_KEY_INPUT_DIR = "SKILL_INPUT_DIR"
CONFIG_KEY_OUTPUT_DIR = "SKILL_OUTPUT_DIR"
CONFIG_KEY_UPLOAD_DIR = "SKILL_UPLOAD_DIR"
CONFIG_KEY_CACHE_DIR = "SKILL_CACHE_DIR"
CONFIG_KEY_TEMP_DIR = "SKILL_TEMP_DIR"
@@ -81,27 +85,25 @@ def resolve_data_path(
def resolve_input_path(raw_path: str, *, create_parent: bool = False) -> str:
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/imports/。"""
"""解析 CLI 输入路径:绝对路径原样;相对路径相对 {skill_data_dir}/input/。"""
text = (raw_path or "").strip()
if not text:
return text
expanded = os.path.expanduser(text)
if os.path.isabs(expanded):
return os.path.abspath(expanded)
imports_dir = get_imports_dir(create=create_parent)
return os.path.abspath(os.path.join(imports_dir, os.path.normpath(expanded)))
input_dir = get_input_dir(create=create_parent)
return os.path.abspath(os.path.join(input_dir, os.path.normpath(expanded)))
def get_downloads_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_DOWNLOAD_DIR, STANDARD_SUBDIR_DOWNLOADS, create=create)
def get_input_dir(*, create: bool = True) -> str:
"""上行:用户/Agent 放入的待处理文件(清单、CSV、产品表等)。"""
return resolve_data_path(CONFIG_KEY_INPUT_DIR, STANDARD_SUBDIR_INPUT, create=create)
def get_imports_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_IMPORT_DIR, STANDARD_SUBDIR_IMPORTS, create=create)
def get_exports_dir(*, create: bool = True) -> str:
return resolve_data_path(CONFIG_KEY_EXPORT_DIR, STANDARD_SUBDIR_EXPORTS, create=create)
def get_output_dir(*, create: bool = True) -> str:
"""下行:技能/宿主交给用户拿走的文件(视频、报表、导出 CSV 等)。"""
return resolve_data_path(CONFIG_KEY_OUTPUT_DIR, STANDARD_SUBDIR_OUTPUT, create=create)
def get_uploads_dir(*, create: bool = True) -> str:
@@ -131,13 +133,21 @@ def get_videos_dir(*, create: bool = True) -> str:
return path
def get_html_dir(*, create: bool = True) -> str:
"""HTML 报告页目录:宿主扫描 `{skill_data}/html/*.html`(一层、非递归)。"""
path = os.path.join(get_skill_data_dir(), STANDARD_SUBDIR_HTML)
if create:
os.makedirs(path, exist_ok=True)
return path
def list_resolved_data_paths() -> dict[str, str]:
"""供 health / config-path 输出已解析的数据子目录(不创建目录)。"""
return {
"skill_data_dir": get_skill_data_dir(),
"downloads_dir": get_downloads_dir(create=False),
"imports_dir": get_imports_dir(create=False),
"exports_dir": get_exports_dir(create=False),
"input_dir": get_input_dir(create=False),
"output_dir": get_output_dir(create=False),
"html_dir": get_html_dir(create=False),
"uploads_dir": get_uploads_dir(create=False),
"cache_dir": get_cache_dir(create=False),
"temp_dir": get_temp_dir(create=False),

View File

@@ -75,7 +75,7 @@ python tests/run_tests.py test_cli_smoke
| **subprocess 真实入口** ``python scripts/main.py`` | ``test_entrypoint_subprocess.py`` |
| 运行路径与 **JIANGCHANG_*** 隔离 | ``test_runtime_paths.py`` |
| ``SKILL.md`` slug 与 ``constants.SKILL_SLUG`` | ``test_skill_metadata.py`` |
| DB / ``task_logs`` 骨架冒烟 | ``test_db_smoke.py`` |
| DB / 元数据骨架冒烟 | ``test_db_smoke.py`` |
| 数据管理元数据 / 字段顺序 | ``test_display_metadata.py`` |
| 标准时间字段(Unix 秒 / trigger / 元数据) | ``test_timestamp_columns.py`` |
| **adapter profile / 外呼授权策略** | ``test_adapter_profile_policy.py`` + ``adapter_test_utils.py``(非 ``test_`` 前缀,不自动当用例收集) |

View File

@@ -35,7 +35,7 @@ def get_skill_root() -> str:
return _SKILL_ROOT
def platform_kit_version_patch(version: str = "1.2.2"):
def platform_kit_version_patch(version: str = "1.2.11"):
"""Mock installed jiangchang-platform-kit version for health/diagnostics tests."""
from unittest.mock import patch
@@ -79,3 +79,63 @@ class IsolatedDataRoot:
if self._tmp and os.path.isdir(self._tmp):
shutil.rmtree(self._tmp, ignore_errors=True)
self._tmp = None
DEMO_ITEMS_TABLE = "demo_items"
DEMO_ITEMS_DDL = """
CREATE TABLE IF NOT EXISTS demo_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
)
"""
DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = (
{"column_name": "id", "display_name": "编号", "description": "主键", "visible": 1, "searchable": 1, "editable": 0},
{"column_name": "title", "display_name": "标题", "description": "标题", "visible": 1, "searchable": 1, "editable": 1},
{"column_name": "status", "display_name": "状态", "description": "状态", "visible": 1, "searchable": 1, "editable": 1},
{
"column_name": "created_at",
"display_name": "创建时间",
"description": "记录创建时间(Unix 秒级时间戳,数据库自动生成)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
{
"column_name": "updated_at",
"display_name": "更新时间",
"description": "记录最后更新时间(Unix 秒级时间戳,自动维护)",
"visible": 1,
"searchable": 0,
"editable": 0,
"display_type": "datetime_unix_seconds",
},
)
def seed_demo_items(cursor, *, readonly: int = 0) -> None:
"""测试夹具:创建 demo_items 物理表、时间戳 trigger 与展示元数据。"""
from db.display_metadata import seed_column_metadata_batch, upsert_table_metadata
from db.timestamp_columns import init_updated_at_trigger
cursor.execute(DEMO_ITEMS_DDL)
init_updated_at_trigger(cursor, DEMO_ITEMS_TABLE)
upsert_table_metadata(
cursor,
table_name=DEMO_ITEMS_TABLE,
display_name="示例记录",
description="模板测试用业务表示例,不是默认交付表",
visible=1,
readonly=readonly,
)
seed_column_metadata_batch(
cursor,
table_name=DEMO_ITEMS_TABLE,
columns=DEMO_ITEMS_COLUMN_METADATA,
valid_column_names=[str(column["column_name"]) for column in DEMO_ITEMS_COLUMN_METADATA],
)

View File

@@ -42,6 +42,10 @@
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
### 5. `test_logging_contract.py.sample`
**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。
---
## 如何启用 service contract 样例

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。
验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。
默认套件不收集本文件(.sample)。
"""
from __future__ import annotations
import io
import logging
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch
# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import
# from _support import IsolatedDataRoot
class TestLoggingContractSample(unittest.TestCase):
"""示范:捕获 skill logger,断言关键事件词出现。"""
def test_main_path_emits_task_start_and_terminal_event(self) -> None:
self.skipTest(
"复制本文件为 tests/test_logging_contract.py 后:"
"用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one,"
"对 logging.Handler 断言消息含 task_start 与 task_success|task_failed"
)
# 示例骨架(复制后实现):
# with IsolatedDataRoot():
# log = logging.getLogger("openclaw.skill.your_skill_slug")
# records: list[str] = []
# class _H(logging.Handler):
# def emit(self, record):
# records.append(record.getMessage())
# h = _H()
# log.addHandler(h)
# try:
# with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
# rc = cmd_add_one(...) # 或 cmd_run
# finally:
# log.removeHandler(h)
# blob = "\n".join(records)
# self.assertIn("task_start", blob)
# self.assertTrue("task_failed" in blob or "task_success" in blob)
# if "task_failed" in blob:
# self.assertIn("error_code=", blob)
def test_failure_path_logs_error_code_before_print(self) -> None:
self.skipTest(
"复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 "
"task_failed error_code=...,且 stdout 含 ERROR:CODE"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -100,7 +100,7 @@ def _load_bindable_business_tables() -> set[str]:
- SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表)
- ``_jiangchang_tables`` 中有对应行且 ``visible=1``
失败时抛出 RuntimeError(带诊断),不得静默返回空集供测试 skip。
模板默认无业务表,允许返回空集。失败时抛出 RuntimeError(带诊断)。
"""
from db.connection import init_db
from db.display_metadata import METADATA_TABLES
@@ -136,12 +136,6 @@ def _load_bindable_business_tables() -> set[str]:
finally:
conn.close()
bindable = physical & visible_meta
if not bindable:
raise RuntimeError(
"isolated init_db produced no bindable tables "
f"(physical={sorted(physical)}, visible_meta={sorted(visible_meta)}, "
f"db_path={db_path!r})"
)
return bindable
except RuntimeError:
raise
@@ -190,7 +184,21 @@ class TestActionsManifest(unittest.TestCase):
placements = action.get("placements")
self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array")
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
replaces = action.get("replaces")
if replaces is not None:
self.assertIn(
replaces,
{"create", "update", "delete", "import"},
msg=f"{action_id} invalid replaces {replaces!r}",
)
bind = action.get("bind") or {}
tables = bind.get("tables") or []
self.assertTrue(
isinstance(tables, list) and len(tables) > 0,
msg=f"{action_id} replaces requires non-empty bind.tables",
)
else:
self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty")
for placement in placements:
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
@@ -216,6 +224,103 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(bind_def.get("required"), ["tables"])
self.assertIs(bind_def.get("additionalProperties"), False)
def test_schema_defines_interaction_fields(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("interaction", props)
self.assertEqual(
set(props["interaction"].get("enum") or []),
{"auto", "direct", "confirm", "form"},
)
self.assertIn("interactionByPlacement", props)
by_placement = props["interactionByPlacement"]
self.assertIs(by_placement.get("additionalProperties"), False)
for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete", "import"):
self.assertIn(key, by_placement.get("properties") or {})
self.assertEqual(
set((by_placement["properties"][key].get("enum") or [])),
{"direct", "confirm", "form"},
)
self.assertIn("replaces", props)
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
self.assertEqual(
set(schema["$defs"]["mutationKind"].get("enum") or []),
{"create", "update", "delete", "import"},
)
def test_schema_defines_hover_hint(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("hoverHint", props)
self.assertNotIn("hoverHint", action_def.get("required") or [])
self.assertEqual(props["hoverHint"].get("type"), "string")
def test_schema_defines_visible_when_on_input_property(self) -> None:
schema = _load_actions_schema()
prop_def = schema["$defs"]["inputProperty"]
props = prop_def["properties"]
self.assertIn("visibleWhen", props)
self.assertNotIn("visibleWhen", prop_def.get("required") or [])
# Must live on input property, not action top-level (≠ Policy L3).
action_props = schema["$defs"]["action"]["properties"]
self.assertNotIn("visibleWhen", action_props)
def test_default_manifest_omits_visible_when(self) -> None:
for action in _load_actions_manifest()["actions"]:
properties = ((action.get("inputSchema") or {}).get("properties")) or {}
for name, prop in properties.items():
self.assertNotIn(
"visibleWhen",
prop if isinstance(prop, dict) else {},
msg=f"{action['id']}.{name} default template should omit visibleWhen",
)
def test_visible_when_example_exists_and_uses_field_rules(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "examples", "visible-when-action.example.json"
)
self.assertTrue(os.path.isfile(path), msg="visible-when example must exist")
with open(path, encoding="utf-8") as f:
example = json.load(f)
props = example["actions"][0]["inputSchema"]["properties"]
self.assertIn("visibleWhen", props["keyword"])
self.assertIn("visibleWhen", props["authorUrl"])
self.assertNotIn("visibleWhen", props["collectMode"])
def test_default_manifest_omits_hover_hint(self) -> None:
for action in _load_actions_manifest()["actions"]:
self.assertNotIn(
"hoverHint",
action,
msg=f"{action['id']} default template action should omit hoverHint",
)
def test_schema_defines_host_effects_and_steps(self) -> None:
schema = _load_actions_schema()
action_def = schema["$defs"]["action"]
props = action_def["properties"]
self.assertIn("hostEffects", props)
self.assertEqual(props["hostEffects"].get("$ref"), "#/$defs/hostEffects")
self.assertIn("steps", props)
effects = schema["$defs"]["hostEffects"]
self.assertIs(effects.get("additionalProperties"), False)
for key in ("onSuccess", "onFailure"):
self.assertIn(key, effects.get("properties") or {})
effect_ids = set(schema["$defs"]["hostEffectId"].get("enum") or [])
self.assertEqual(effect_ids, {"reloadTable", "refreshCatalog", "clearSelection"})
step = schema["$defs"]["actionStep"]
self.assertEqual(
set(step["properties"]["when"].get("enum") or []),
{"before", "afterSuccess", "afterFailure"},
)
self.assertEqual(
set(step["properties"]["do"].get("enum") or []),
{"deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"},
)
self.assertEqual(props["steps"].get("maxItems"), 8)
def test_manifest_validates_against_json_schema(self) -> None:
try:
import jsonschema
@@ -249,15 +354,14 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(errors, [], msg="\n".join(errors))
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
"""helper 本身必须可用:即便默认 manifest 无 toolbar bind,也要验证隔离库可读。"""
"""helper 本身必须可用:模板默认无业务表,隔离库仍应可读且不含元数据表。"""
bindable = _load_bindable_business_tables()
self.assertIn("task_logs", bindable)
self.assertEqual(bindable, set())
self.assertNotIn("_jiangchang_tables", bindable)
self.assertNotIn("_jiangchang_columns", bindable)
def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None:
bindable = _load_bindable_business_tables()
self.assertTrue(bindable, msg="bindable table set must be non-empty after init_db")
manifest = _load_actions_manifest()
missing: list[str] = []
for action in manifest["actions"]:
@@ -293,7 +397,7 @@ class TestActionsManifest(unittest.TestCase):
"entrypoint": {"type": "cli", "command": "health", "args": []},
}
if placement == "toolbar":
action["bind"] = {"tables": ["task_logs"]}
action["bind"] = {"tables": ["demo_items"]}
manifest = {
"schemaVersion": 1,
"skill": "your-skill-slug",
@@ -469,8 +573,44 @@ class TestActionsManifest(unittest.TestCase):
"additionalProperties",
"placements",
"executionProfile",
"interaction",
"confirmation",
"数据管理技能按钮顺序",
"数组相对顺序",
"全部直接展示",
"左右分栏",
"行级入口",
"行右键",
"六职责正交",
"POLICY.md",
"hostEffects",
"steps",
"hoverHint",
"visibleWhen",
"字段条件显隐",
"reloadTable",
"afterSuccess",
"afterFailure",
"cellPresentation",
"CELL_PRESENTATION.md",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
self.assertNotIn(
"其余进「更多操作」",
text,
msg="ACTIONS.md must not document removed overflow more-menu truncation",
)
self.assertIn(
"新增 `row-context-menu` 等 placement",
text,
msg="ACTIONS.md must forbid inventing a separate row-context-menu placement",
)
placement_enum = _load_actions_schema().get("$defs", {}).get("placement", {}).get("enum") or []
self.assertNotIn(
"row-context-menu",
placement_enum,
msg="skill-actions.schema must not add row-context-menu to placements enum",
)
for forbidden in (
"数据管理 toolbar " + "只挂",
"toolbar " + "只放 async",

View File

@@ -0,0 +1,93 @@
# -*- coding: utf-8 -*-
"""Cell Presentation 契约:schema + 文档 + 示例。"""
from __future__ import annotations
import json
import os
import unittest
from util.runtime_paths import get_skill_root
def _load_schema() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"schemas",
"skill-cell-presentation.schema.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
def _load_example() -> dict:
path = os.path.join(
get_skill_root(),
"assets",
"examples",
"skill-cell-presentation.example.json",
)
with open(path, encoding="utf-8") as f:
return json.load(f)
class TestCellPresentationContract(unittest.TestCase):
def test_schema_file_exists_and_parses(self) -> None:
schema = _load_schema()
self.assertEqual(schema.get("title"), "SkillCellPresentationDocument")
self.assertIs(schema.get("additionalProperties"), False)
self.assertEqual(schema["properties"]["schemaVersion"].get("const"), 1)
tones = set(schema["$defs"]["tone"].get("enum") or [])
self.assertEqual(tones, {"danger", "warning", "info", "success", "muted"})
def test_example_validates_against_schema(self) -> None:
try:
import jsonschema
except ImportError:
self.skipTest("jsonschema not installed")
jsonschema.validate(instance=_load_example(), schema=_load_schema())
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
path = os.path.join(
get_skill_root(), "assets", "schemas", "skill-actions.schema.json"
)
with open(path, encoding="utf-8") as f:
actions_schema = json.load(f)
self.assertIn("cellPresentation", actions_schema["properties"])
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
def test_docs_and_example_exist(self) -> None:
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "examples", "skill-cell-presentation.example.json")
)
)
def test_cell_presentation_md_covers_contract(self) -> None:
path = os.path.join(get_skill_root(), "references", "CELL_PRESENTATION.md")
with open(path, encoding="utf-8") as f:
text = f.read()
for marker in (
"tone",
"danger",
"warning",
"alert",
"_jiangchang_cell_presentation",
"cellPresentation",
"派生",
"任意 CSS",
"用途不限",
"schemaVersion",
):
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
if __name__ == "__main__":
unittest.main()

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""通用 CLI 冒烟:用法、health、version、logs、log-get(不触网、不深测 run 占位)。"""
"""通用 CLI 冒烟:用法、health、version、init-db(不触网、不深测 run 占位)。"""
from __future__ import annotations
import io
@@ -78,20 +78,13 @@ class TestCliSmoke(unittest.TestCase):
self.assertTrue(payload2["ok"])
self.assertEqual(payload2["db_path"], payload["db_path"])
def test_logs_empty_returns_zero(self) -> None:
with IsolatedDataRoot():
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["logs"])
self.assertEqual(rc, 0)
self.assertIn("暂无", buf.getvalue())
def test_log_get_non_numeric_returns_nonzero(self) -> None:
def test_unknown_logs_subcommand_is_rejected(self) -> None:
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = main(["log-get", "not-a-number"])
self.assertNotEqual(rc, 0)
self.assertIn("数字", buf.getvalue())
err = io.StringIO()
with redirect_stdout(buf), redirect_stderr(err):
with self.assertRaises(SystemExit) as caught:
main(["logs"])
self.assertNotEqual(caught.exception.code, 0)
def test_run_placeholder_returns_nonzero_without_network(self) -> None:
"""占位 run:RpaVideoSession 示范 + 模板提示;OPENCLAW_RECORD_VIDEO=0 时不启 ffmpeg。"""

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""task_logs 表:init 幂等、写入与查询(全程隔离数据目录)。"""
"""init_db:幂等创建元数据表,默认不建业务表。"""
from __future__ import annotations
import unittest
@@ -8,44 +8,30 @@ from _support import IsolatedDataRoot
class TestDbSmoke(unittest.TestCase):
def test_init_db_idempotent_and_crud_smoke(self) -> None:
def test_init_db_idempotent_metadata_only(self) -> None:
with IsolatedDataRoot():
from db import task_logs_repository as tlr
from db.connection import get_conn, init_db
from db.display_metadata import METADATA_TABLES, iter_user_tables
init_db()
init_db() # 幂等
init_db()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'")
self.assertEqual(cur.fetchone()[0], "任务日志")
cur.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
)
tables = {str(row[0]) for row in cur.fetchall()}
self.assertIn("_jiangchang_tables", tables)
self.assertIn("_jiangchang_columns", tables)
self.assertNotIn("task_logs", tables)
self.assertEqual(list(iter_user_tables(cur)), [])
leftover = tables - METADATA_TABLES
self.assertEqual(leftover, set())
finally:
conn.close()
new_id = tlr.save_task_log(
task_type="demo_task",
target_id="tgt-1",
input_id="42",
input_title="t",
status="success",
error_msg=None,
result_summary=None,
)
self.assertIsInstance(new_id, int)
self.assertGreater(new_id, 0)
rows = tlr.list_task_logs(limit=10)
self.assertTrue(any(r[0] == new_id for r in rows))
row = tlr.get_task_log_by_id(new_id)
self.assertIsNotNone(row)
self.assertEqual(int(row[0]), new_id)
missing = tlr.get_task_log_by_id(999_999)
self.assertIsNone(missing)
if __name__ == "__main__":
unittest.main()

View File

@@ -37,6 +37,7 @@ POLICY_IDS = (
"POLICY-RPA-003",
"POLICY-RPA-004",
"POLICY-RPA-005",
"POLICY-RPA-006",
"POLICY-PACKAGING-001",
"POLICY-PACKAGING-002",
"POLICY-DOCS-001",
@@ -45,6 +46,9 @@ POLICY_IDS = (
"POLICY-LOGGING-003",
"POLICY-LOGGING-004",
"POLICY-LOGGING-005",
"POLICY-LOGGING-006",
"POLICY-LOGGING-007",
"POLICY-LOGGING-008",
"POLICY-CONTROL-001",
"POLICY-CONTROL-002",
"POLICY-CONTROL-003",
@@ -175,11 +179,51 @@ RPA_005_STEP_DELAY_KEY_RE = re.compile(
r"(?m)^\s*(STEP_DELAY_MIN|STEP_DELAY_MAX)\s*=\s*([^\s#]+)"
)
RPA_006_SOURCE = (
"development/ACCOUNT_MANAGER.md; development/ADAPTER.md §兄弟依赖; "
"development/RPA.md §0.2; development/POLICY_MATRIX.md"
)
RPA_006_REQUIRED_DOC_MARKERS = (
"一账号一",
"profile_dir",
"lease_token",
"数据管理页面",
"account-manager.db",
"account list",
"lease list",
"关闭浏览器并释放租约",
"两个以上账号",
)
RPA_006_EXAMPLE_CLIENTS = (
"examples/real_browser_rpa/scripts/service/account_client.py",
"examples/simulator_browser_rpa/scripts/service/account_client.py",
)
LOGGING_001_SOURCE = "development/LOGGING.md; development/RUNTIME.md"
LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md"
LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md"
LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md"
LOGGING_006_SOURCE = "development/LOGGING.md §0、§3"
LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2"
LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3"
LOGGING_006_EXEMPT_BASENAMES = frozenset(
{
"__init__.py",
"task_run_support.py",
"entitlement_service.py",
"sibling_bridge.py",
}
)
LOGGING_RPA_MARKERS = (
"RpaVideoSession",
"uiautomation",
"playwright",
"@rpa_step",
"rpa_step(",
)
LOGGING_CALL_PATTERN = re.compile(
r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(",
@@ -736,6 +780,40 @@ class TestPolicyRpa005(unittest.TestCase):
)
class TestPolicyRpa006(unittest.TestCase):
def test_account_manager_lifecycle_doc_has_required_markers(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "development", "ACCOUNT_MANAGER.md")
self.assertTrue(
os.path.isfile(path),
msg=_policy_msg("POLICY-RPA-006", RPA_006_SOURCE, "development/ACCOUNT_MANAGER.md missing"),
)
text = _read_text(path)
missing = [marker for marker in RPA_006_REQUIRED_DOC_MARKERS if marker not in text]
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-RPA-006",
RPA_006_SOURCE,
"ACCOUNT_MANAGER.md missing markers: " + ", ".join(missing),
),
)
def test_example_account_id_selection_keeps_lease(self) -> None:
skill_root = get_skill_root()
for rel in RPA_006_EXAMPLE_CLIENTS:
text = _read_text(os.path.join(skill_root, *rel.split("/")))
self.assertIn("_pick_by_id_with_lease", text, msg=f"{rel} must lease explicit account ids")
self.assertIn('"account",\n "pick-web"', text, msg=f"{rel} must use pick-web for lease")
self.assertIn("lease_token", text, msg=f"{rel} must validate lease_token")
self.assertNotIn(
'data["lease_token"] = ""',
text,
msg=f"{rel} must not return an explicit account without a lease",
)
def _scan_sensitive_logging_assignments(skill_root: str) -> list[str]:
offenders: list[str] = []
for path in _walk_files(skill_root, "scripts", suffix=".py"):
@@ -798,7 +876,7 @@ class TestPolicyLogging002(unittest.TestCase):
class TestPolicyLogging003(unittest.TestCase):
def test_task_service_logs_start_failure_and_task_logs(self) -> None:
def test_task_service_logs_start_and_failure(self) -> None:
skill_root = get_skill_root()
path = os.path.join(skill_root, "scripts", "service", "task_service.py")
text = _read_text(path)
@@ -808,8 +886,6 @@ class TestPolicyLogging003(unittest.TestCase):
missing.append("log.info or logger.info")
if not re.search(r"\b(log|logger)\.exception\s*\(", text):
missing.append("log.exception or logger.exception")
if "save_task_log" not in text:
missing.append("save_task_log")
self.assertEqual(
missing,
[],
@@ -856,6 +932,121 @@ class TestPolicyLogging005(unittest.TestCase):
)
def _iter_service_py_files(skill_root: str) -> list[str]:
service_dir = os.path.join(skill_root, "scripts", "service")
if not os.path.isdir(service_dir):
return []
return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py"))
def _service_layer_combined_text(skill_root: str) -> str:
parts: list[str] = []
for path in _iter_service_py_files(skill_root):
parts.append(_read_text(path))
return "\n".join(parts)
_LOGGING_DOMAIN_CALL_RE = re.compile(
r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(",
re.IGNORECASE,
)
_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL)
_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(")
def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]:
"""同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。"""
offenders: list[str] = []
matches = list(_FUNC_DEF_RE.finditer(text))
if not matches:
return offenders
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end]
if not _PRINT_ERROR_RE.search(body):
continue
if re.search(
r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(",
body,
re.IGNORECASE,
):
continue
lineno = text[:start].count("\n") + 1
offenders.append(
f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function"
)
return offenders
class TestPolicyLogging006(unittest.TestCase):
def test_service_domain_modules_have_file_logging(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
base = os.path.basename(path)
if base in LOGGING_006_EXEMPT_BASENAMES:
continue
text = _read_text(path)
if not _LOGGING_DOMAIN_CALL_RE.search(text):
offenders.append(_rel(skill_root, path))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-006",
LOGGING_006_SOURCE,
"service domain modules missing log/logger/_logger.(info|warning|error|exception):\n"
+ "\n".join(offenders),
),
)
class TestPolicyLogging007(unittest.TestCase):
def test_rpa_service_layer_has_event_tokens(self) -> None:
skill_root = get_skill_root()
combined = _service_layer_combined_text(skill_root)
if not any(marker in combined for marker in LOGGING_RPA_MARKERS):
self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A")
missing: list[str] = []
if "task_start" not in combined:
missing.append("task_start")
if "task_failed" not in combined and "rpa_failed" not in combined:
missing.append("task_failed|rpa_failed")
if "stage=" not in combined:
missing.append("stage=")
# entitlement tokens strongly recommended; require at least one
if "entitlement_ok" not in combined and "entitlement_failed" not in combined:
missing.append("entitlement_ok|entitlement_failed")
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-LOGGING-007",
LOGGING_007_SOURCE,
"RPA service layer missing event tokens: " + ", ".join(missing),
),
)
class TestPolicyLogging008(unittest.TestCase):
def test_print_error_paired_with_logger(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
text = _read_text(path)
offenders.extend(_scan_print_error_without_logger(skill_root, path, text))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-008",
LOGGING_008_SOURCE,
"\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception",
),
)
class TestPolicyControl001(unittest.TestCase):
def test_no_util_progress_module(self) -> None:
skill_root = get_skill_root()

View File

@@ -6,7 +6,13 @@ import os
import sqlite3
import unittest
from _support import IsolatedDataRoot, get_skill_root
from _support import (
DEMO_ITEMS_COLUMN_METADATA,
DEMO_ITEMS_TABLE,
IsolatedDataRoot,
get_skill_root,
seed_demo_items,
)
class TestDisplayMetadata(unittest.TestCase):
@@ -20,14 +26,18 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 1)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 10)
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
self.assertEqual(int(cur.fetchone()[0]), 0)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
self.assertEqual(int(cur.fetchone()[0]), 0)
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
)
self.assertIsNone(cur.fetchone())
finally:
conn.close()
def test_task_logs_has_chinese_table_and_column_names(self) -> None:
def test_demo_items_fixture_has_chinese_names(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
@@ -35,32 +45,35 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = 'task_logs'"
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = ?",
(DEMO_ITEMS_TABLE,),
)
display_name, readonly = cur.fetchone()
self.assertEqual(display_name, "任务日志")
self.assertEqual(int(readonly), 1)
self.assertEqual(display_name, "示例记录")
self.assertEqual(int(readonly), 0)
cur.execute(
"""
SELECT column_name, display_name, editable
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
WHERE table_name = ?
ORDER BY column_name
"""
""",
(DEMO_ITEMS_TABLE,),
)
by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
self.assertEqual(by_name["status"][0], "状态")
self.assertEqual(int(by_name["status"][1]), 0)
self.assertEqual(by_name["error_msg"][0], "错误信息")
self.assertEqual(int(by_name["error_msg"][1]), 0)
self.assertEqual(int(by_name["status"][1]), 1)
self.assertEqual(by_name["title"][0], "标题")
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(int(by_name["created_at"][1]), 0)
finally:
conn.close()
def test_task_logs_not_manual_create_in_host_ui(self) -> None:
def test_readonly_table_not_manual_create_in_host_ui(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import table_allows_manual_create
@@ -68,33 +81,34 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
self.assertFalse(table_allows_manual_create(conn.cursor(), "task_logs"))
cur = conn.cursor()
seed_demo_items(cur, readonly=1)
conn.commit()
self.assertFalse(table_allows_manual_create(cur, DEMO_ITEMS_TABLE))
finally:
conn.close()
def test_task_logs_physical_order_matches_pragma_cid(self) -> None:
def test_demo_items_physical_order_matches_pragma_cid(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from db.display_metadata import TASK_LOGS_COLUMN_METADATA, list_physical_column_names
from db.connection import get_conn
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
init_db()
conn = get_conn()
try:
cur = conn.cursor()
physical = list_physical_column_names(cur, "task_logs")
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
self.assertEqual(
physical,
expected,
)
cur.execute("PRAGMA table_info('task_logs')")
seed_demo_items(cur)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
self.assertEqual(physical, expected)
cur.execute("PRAGMA table_info('demo_items')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_init_db_sets_task_logs_display_order_null(self) -> None:
def test_seed_sets_display_order_null(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
@@ -102,12 +116,15 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT column_name, display_order
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
"""
WHERE table_name = ?
""",
(DEMO_ITEMS_TABLE,),
)
for column_name, display_order in cur.fetchall():
self.assertIsNone(
@@ -117,33 +134,32 @@ class TestDisplayMetadata(unittest.TestCase):
finally:
conn.close()
def test_reinit_clears_stale_display_order(self) -> None:
def test_reseed_clears_stale_display_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT display_order FROM _jiangchang_columns
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
self.assertIsNone(cur.fetchone()[0])
finally:
@@ -158,22 +174,26 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 1
WHERE table_name = 'task_logs' AND column_name = 'updated_at'
"""
WHERE table_name = ? AND column_name = 'updated_at'
""",
(DEMO_ITEMS_TABLE,),
)
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
physical = list_physical_column_names(cur, "task_logs")
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
self.assertEqual(physical[0], "id")
self.assertEqual(physical[-1], "updated_at")
finally:
@@ -188,7 +208,7 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
tables = list(iter_user_tables(conn.cursor()))
self.assertEqual(tables, ["task_logs"])
self.assertEqual(tables, [])
finally:
conn.close()
@@ -205,6 +225,37 @@ class TestDisplayMetadata(unittest.TestCase):
finally:
conn.close()
def test_validate_rejects_sqlite_foreign_keys_on_business_tables(self) -> None:
from db.display_metadata import (
_JIANGCHANG_COLUMNS_DDL,
_JIANGCHANG_TABLES_DDL,
)
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_TABLES_DDL)
conn.execute(_JIANGCHANG_COLUMNS_DDL)
conn.execute("CREATE TABLE parent_rows (id INTEGER PRIMARY KEY, title TEXT)")
conn.execute(
"""
CREATE TABLE child_rows (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES parent_rows(id)
)
"""
)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(
any("FOREIGN KEY" in err or "REFERENCES" in err for err in report.errors),
msg=f"expected FK error, got: {report.errors}",
)
self.assertTrue(any("child_rows" in err for err in report.errors))
finally:
conn.close()
def test_validate_missing_both_metadata_tables(self) -> None:
conn = sqlite3.connect(":memory:")
try:
@@ -271,13 +322,15 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = 'task_logs' AND column_name = 'status'"
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = 'status'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("task_logs.status" in err for err in report.errors))
self.assertTrue(any("demo_items.status" in err for err in report.errors))
finally:
conn.close()
@@ -289,12 +342,14 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('task_logs', 'not_exists', '不存在', 1, 1, 1)
"""
VALUES (?, 'not_exists', '不存在', 1, 1, 1)
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
@@ -498,15 +553,156 @@ metadata:
finally:
conn.close()
def test_schema_md_documents_task_logs_readonly_semantics(self) -> None:
def test_schema_md_documents_readonly_and_seed_helpers(self) -> None:
path = os.path.join(get_skill_root(), "references", "SCHEMA.md")
with open(path, encoding="utf-8") as f:
text = f.read()
self.assertIn("readonly = 1", text)
self.assertIn("task_logs", text)
self.assertIn("seed_column_metadata_batch", text)
self.assertIn("valid_column_names", text)
def test_default_visible_columns_json_upsert_and_migrate(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
upsert_table_metadata,
)
cur = conn.cursor()
# Simulate pre-1.0.79 table without the new column.
cur.execute(
"""
CREATE TABLE _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT
)
"""
)
init_display_metadata_tables(cur)
cur.execute("PRAGMA table_info('_jiangchang_tables')")
cols = {str(row[1]) for row in cur.fetchall()}
self.assertIn("default_visible_columns_json", cols)
payload = '["stat_type","period","stage"]'
upsert_table_metadata(
cur,
table_name="timeliness_stats",
display_name="时效统计",
readonly=1,
default_visible_columns_json=payload,
)
conn.commit()
cur.execute(
"""
SELECT default_visible_columns_json
FROM _jiangchang_tables
WHERE table_name = 'timeliness_stats'
"""
)
self.assertEqual(cur.fetchone()[0], payload)
finally:
conn.close()
def test_grid_help_upsert_and_migrate_from_legacy_schema(self) -> None:
conn = sqlite3.connect(":memory:")
try:
from db.display_metadata import (
init_display_metadata_tables,
upsert_column_metadata,
upsert_table_metadata,
)
cur = conn.cursor()
cur.execute(
"""
CREATE TABLE _jiangchang_tables (
table_name TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
description TEXT,
sort_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
readonly INTEGER NOT NULL DEFAULT 0,
default_sort_json TEXT,
default_visible_columns_json TEXT
)
"""
)
cur.execute(
"""
CREATE TABLE _jiangchang_columns (
table_name TEXT NOT NULL,
column_name TEXT NOT NULL,
display_name TEXT NOT NULL,
description TEXT,
display_order INTEGER,
visible INTEGER NOT NULL DEFAULT 1,
searchable INTEGER NOT NULL DEFAULT 1,
editable INTEGER NOT NULL DEFAULT 1,
display_type TEXT,
options_json TEXT,
PRIMARY KEY (table_name, column_name)
)
"""
)
init_display_metadata_tables(cur)
cur.execute("PRAGMA table_info('_jiangchang_tables')")
self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()})
cur.execute("PRAGMA table_info('_jiangchang_columns')")
self.assertIn("grid_help", {str(row[1]) for row in cur.fetchall()})
upsert_table_metadata(
cur,
table_name="demo_items",
display_name="示例记录",
description="表单备注,不应出现在表格横幅",
grid_help="表格横幅说明",
)
upsert_column_metadata(
cur,
table_name="demo_items",
column_name="title",
display_name="标题",
description="填标题",
grid_help="列头问号说明",
)
upsert_table_metadata(
cur,
table_name="empty_help",
display_name="无表格说明",
)
conn.commit()
cur.execute(
"""
SELECT description, grid_help
FROM _jiangchang_tables
WHERE table_name = 'demo_items'
"""
)
self.assertEqual(cur.fetchone(), ("表单备注,不应出现在表格横幅", "表格横幅说明"))
cur.execute(
"""
SELECT description, grid_help
FROM _jiangchang_columns
WHERE table_name = 'demo_items' AND column_name = 'title'
"""
)
self.assertEqual(cur.fetchone(), ("填标题", "列头问号说明"))
cur.execute(
"""
SELECT grid_help FROM _jiangchang_tables
WHERE table_name = 'empty_help'
"""
)
self.assertTrue(not cur.fetchone()[0])
finally:
conn.close()
if __name__ == "__main__":
unittest.main()

View File

@@ -249,6 +249,21 @@ class TestDocsStandards(unittest.TestCase):
msg="RPA.md §0.2 must separate site-login from Profile requirement",
)
def test_account_manager_standard_is_discoverable(self) -> None:
account_text = self._read("development/ACCOUNT_MANAGER.md")
for marker in (
"一账号一",
"profile_dir",
"lease_token",
"数据管理",
"account-manager.db",
"关闭浏览器并释放租约",
"两个以上账号",
):
self.assertIn(marker, account_text)
for rel in ("SKILL.md", "development/README.md", "development/RPA.md"):
self.assertIn("ACCOUNT_MANAGER.md", self._read(rel), msg=f"{rel} must link account standard")
def test_simulator_example_readme_covers_async_and_account_manager(self) -> None:
text = self._read("examples/simulator_browser_rpa/README.md")
self.assertIn("async", text.lower())
@@ -284,9 +299,22 @@ class TestDocsStandards(unittest.TestCase):
root = get_skill_root()
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "actions.json")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "ACTIONS.md")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "POLICY.md")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "CELL_PRESENTATION.md")))
self.assertTrue(os.path.isfile(os.path.join(root, "references", "WORK_ITEM_STATUS.md")))
self.assertTrue(
os.path.isfile(os.path.join(root, "assets", "schemas", "skill-actions.schema.json"))
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-policy-snapshot.schema.json")
)
)
self.assertTrue(
os.path.isfile(
os.path.join(root, "assets", "schemas", "skill-cell-presentation.schema.json")
)
)
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_actions_manifest.py")))
self.assertTrue(os.path.isfile(os.path.join(root, "tests", "test_data_management_contract.py")))
@@ -310,9 +338,69 @@ class TestDocsStandards(unittest.TestCase):
"options_json",
"demo_items",
"readonly",
"新增/编辑表单:字段说明与默认值(宿主支持)",
"表单与展示:稳定字段类型(宿主支持,须与 Action 对称)",
"datetime_unix_seconds",
"display_type: \"number\"",
"extra_column",
"{value} · {label}",
"DEFAULT",
"description",
"文件 / 图片上传",
"default_visible_columns_json",
"默认展示列",
"grid_help",
"表格说明 `grid_help`",
"表与表之间:禁止 SQLite 外键",
"禁止 SQLite 外键",
"跨表引用 lookup",
"\"multiple\": true",
"Lookup 多选",
):
self.assertIn(marker, text, msg=f"SCHEMA.md missing {marker!r}")
def test_actions_md_documents_form_schema_split(self) -> None:
text = self._read("references/ACTIONS.md")
for marker in (
"inputSchema 与参数表单",
"表单字段类型对照(须与 CRUD 对称)",
"跨表引用:Lookup",
"display_type: \"lookup\"",
"extra_column",
"multiple",
"数据管理新增/编辑",
"_jiangchang_columns.description",
"datetime_unix_seconds",
"SCHEMA.md",
"字段条件显隐",
"visibleWhen",
"grid_help",
):
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
def test_actions_md_disambiguates_visible_when_from_policy(self) -> None:
actions = self._read("references/ACTIONS.md")
policy = self._read("references/POLICY.md")
self.assertIn("字段条件显隐", actions)
self.assertIn("inputSchema.properties", actions)
self.assertTrue(
"POLICY.md" in actions and "L3" in actions,
msg="ACTIONS.md must point readers away from Policy L3 visibleWhen",
)
self.assertTrue(
"inputSchema" in policy and "ACTIONS.md" in policy,
msg="POLICY.md must disambiguate L3 visibleWhen from Action form visibleWhen",
)
def test_visible_when_example_is_listed(self) -> None:
text = self._read("assets/examples/README.md")
self.assertIn("visible-when-action.example.json", text)
checklist = self._read("development/CHECKLIST.md")
self.assertIn("A4-7", checklist)
self.assertIn("visibleWhen", checklist)
runtime = self._read("development/SKILL_ACTION_RUNTIME.md")
self.assertIn("visibleWhen", runtime)
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
forbidden = (
"download-settlement-report-amazon",
@@ -409,6 +497,7 @@ class TestDocsStandards(unittest.TestCase):
text = self._read("references/ACTIONS.md")
self.assertIn("最小组合", text)
self.assertIn("confirmation.message", text)
self.assertIn("hoverHint", text)
self.assertIn("用户意图路由表", text)
def test_actions_md_states_profile_not_entry_for_task_center(self) -> None:
@@ -483,12 +572,60 @@ class TestDocsStandards(unittest.TestCase):
def test_examples_top_level_modes_present(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples")
for mode in ("real_" + "api", "real_browser_rpa", "simulator_api", "simulator_browser_rpa"):
for mode in (
"real_" + "api",
"real_browser_rpa",
"simulator_api",
"simulator_browser_rpa",
"real_desktop_rpa",
"mobile_rpa",
):
self.assertTrue(
os.path.isdir(os.path.join(examples_dir, mode)),
msg=f"examples/{mode}/ must exist",
)
def test_three_surface_locator_standards_are_present(self) -> None:
required = {
"development/RPA_ELEMENT_LOCATING.md": (
"元素清单",
"expected_count",
"ERROR:RESULT_UNCONFIRMED",
"AI 编程约束",
),
"development/RPA_BROWSER.md": (
"Pick Locator",
"iframe",
"Shadow DOM",
"禁止模式",
),
"development/RPA_DESKTOP.md": (
"uiautomation",
"add-friend-wecom",
"AutomationId",
"probe-ui",
),
"development/RPA_MOBILE.md": (
"Appium Inspector",
"resourceId",
"Native/WebView",
"尚无团队真实成功案例",
),
}
for rel, markers in required.items():
text = self._read(rel)
for marker in markers:
self.assertIn(marker, text, msg=f"{rel} missing {marker!r}")
def test_desktop_and_mobile_examples_state_validation_level(self) -> None:
desktop = self._read("examples/real_desktop_rpa/README.md")
self.assertIn("技术路线已真实验证", desktop)
self.assertIn("add-friend-wecom", desktop)
self.assertIn("dry-run", desktop)
mobile = self._read("examples/mobile_rpa/README.md")
self.assertIn("不是可复制实现", mobile)
self.assertIn("不得宣称已跑通", mobile)
def test_examples_are_not_full_skill_repos(self) -> None:
examples_dir = os.path.join(get_skill_root(), "examples")
for mode in os.listdir(examples_dir):

View File

@@ -0,0 +1,66 @@
# -*- coding: utf-8 -*-
"""HTML 报告页薄写入 helper。"""
from __future__ import annotations
import os
import unittest
from _support import IsolatedDataRoot
from service.html_page_service import (
HOST_HTML_RENDER_ACTION_ID,
load_html_template,
render_html_placeholders,
write_html_page,
)
class TestHtmlPageService(unittest.TestCase):
def test_host_action_id_is_stable(self) -> None:
self.assertEqual(HOST_HTML_RENDER_ACTION_ID, "render-html")
def test_write_html_page_atomic_under_html_dir(self) -> None:
with IsolatedDataRoot() as tmp:
path = write_html_page(
"example-report.html",
"<!DOCTYPE html><html><head><title>示例</title></head><body>ok</body></html>",
)
self.assertTrue(os.path.isfile(path))
norm = path.replace("\\", "/")
self.assertIn("/html/example-report.html", norm)
self.assertTrue(os.path.normcase(os.path.normpath(path)).startswith(
os.path.normcase(os.path.normpath(tmp)),
))
self.assertFalse(os.path.isfile(path + ".tmp"))
with open(path, encoding="utf-8") as f:
self.assertIn("<title>示例</title>", f.read())
def test_load_and_fill_example_template(self) -> None:
with IsolatedDataRoot():
template = load_html_template("assets/html/example-report.html")
page = render_html_placeholders(
template,
{
"TITLE": "示例报告",
"GENERATED_AT": "2026-08-14 15:49:00",
"BODY": "<p>hello</p>",
},
)
self.assertIn("<title>示例报告</title>", page)
self.assertIn("报告生成于 · 2026-08-14 15:49:00", page)
self.assertIn("<p>hello</p>", page)
self.assertNotIn("{{TITLE}}", page)
dest = write_html_page("example-report.html", page)
self.assertTrue(os.path.isfile(dest))
def test_rejects_unsafe_filename(self) -> None:
with IsolatedDataRoot():
with self.assertRaises(ValueError):
write_html_page("../x.html", "<html></html>")
with self.assertRaises(ValueError):
write_html_page("note.txt", "<html></html>")
if __name__ == "__main__":
unittest.main()

View File

@@ -87,12 +87,12 @@ class TestPlatformImportSource(unittest.TestCase):
from jiangchang_skill_core import version_ge
from util.constants import PLATFORM_KIT_MIN_VERSION
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.2")
self.assertEqual(PLATFORM_KIT_MIN_VERSION, "1.2.11")
md_path = os.path.join(get_skill_root(), "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
self.assertEqual(_parse_platform_kit_min_version(md), "1.2.2")
self.assertEqual(_parse_platform_kit_min_version(md), "1.2.11")
req_path = os.path.join(get_skill_root(), "requirements.txt")
with open(req_path, encoding="utf-8") as f:

View File

@@ -89,11 +89,11 @@ class TestRuntimePaths(unittest.TestCase):
import util.runtime_paths as rp
importlib.reload(rp)
path = rp.resolve_data_path(None, "downloads", create=True)
path = rp.resolve_data_path(None, "output", create=True)
norm = os.path.normcase(os.path.normpath(path))
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(norm.startswith(norm_tmp))
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("downloads"))))
self.assertTrue(norm.endswith(os.path.normcase(os.path.join("output"))))
def test_resolve_data_path_relative_uses_data_dir_not_cwd(self) -> None:
with IsolatedDataRoot() as tmp:
@@ -102,20 +102,20 @@ class TestRuntimePaths(unittest.TestCase):
import util.runtime_paths as rp
importlib.reload(rp)
os.environ["SKILL_DOWNLOAD_DIR"] = "downloads/videos"
os.environ["SKILL_OUTPUT_DIR"] = "output/videos"
from jiangchang_skill_core import config
config.reset_cache()
importlib.reload(rp)
path = rp.resolve_data_path("SKILL_DOWNLOAD_DIR", "downloads", create=False)
path = rp.resolve_data_path("SKILL_OUTPUT_DIR", "output", create=False)
norm = os.path.normcase(os.path.normpath(path))
self.assertIn(os.path.normcase("downloads"), norm)
self.assertIn(os.path.normcase("output"), norm)
self.assertIn(os.path.normcase("videos"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
os.environ.pop("SKILL_DOWNLOAD_DIR", None)
os.environ.pop("SKILL_OUTPUT_DIR", None)
config.reset_cache()
def test_resolve_input_path_relative_under_imports(self) -> None:
def test_resolve_input_path_relative_under_input(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
@@ -124,9 +124,27 @@ class TestRuntimePaths(unittest.TestCase):
importlib.reload(rp)
resolved = rp.resolve_input_path("urls.txt", create_parent=True)
norm = os.path.normcase(os.path.normpath(resolved))
self.assertIn(os.path.normcase("imports"), norm)
self.assertIn(os.path.normcase("input"), norm)
self.assertTrue(norm.startswith(os.path.normcase(os.path.normpath(tmp))))
def test_get_input_and_output_dirs(self) -> None:
with IsolatedDataRoot() as tmp:
import importlib
import util.runtime_paths as rp
importlib.reload(rp)
input_dir = rp.get_input_dir(create=True)
output_dir = rp.get_output_dir(create=True)
html_dir = rp.get_html_dir(create=True)
norm_tmp = os.path.normcase(os.path.normpath(tmp))
self.assertTrue(os.path.normcase(os.path.normpath(input_dir)).startswith(norm_tmp))
self.assertTrue(os.path.normcase(os.path.normpath(output_dir)).startswith(norm_tmp))
self.assertTrue(os.path.normcase(os.path.normpath(html_dir)).startswith(norm_tmp))
self.assertTrue(input_dir.replace("\\", "/").endswith("/input"))
self.assertTrue(output_dir.replace("\\", "/").endswith("/output"))
self.assertTrue(html_dir.replace("\\", "/").endswith("/html"))
def test_list_resolved_data_paths_keys(self) -> None:
with IsolatedDataRoot():
import importlib
@@ -137,13 +155,15 @@ class TestRuntimePaths(unittest.TestCase):
paths = rp.list_resolved_data_paths()
for key in (
"skill_data_dir",
"downloads_dir",
"imports_dir",
"exports_dir",
"input_dir",
"output_dir",
"html_dir",
):
self.assertIn(key, paths)
self.assertTrue(paths[key])
self.assertNotIn("downloads_dir", paths)
self.assertNotIn("imports_dir", paths)
self.assertNotIn("exports_dir", paths)
if __name__ == "__main__":
unittest.main()

Some files were not shown because too many files have changed in this diff Show More