Compare commits
56 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a8403ce9b4 | |||
| 8c8811e777 | |||
| a70da6a076 | |||
| a125b00285 | |||
| 2a3e971c76 | |||
| db2ed089a3 | |||
| c43c26ad15 | |||
| fe4209b37f | |||
| 3bebd6468f | |||
| e7fa3c79b5 | |||
| 0ce0c3e852 | |||
| 201d4d1367 | |||
| 295b2356ac | |||
| 7257d09813 | |||
| 9d33c5ea2c | |||
| b5d120469b | |||
| d35d04f779 | |||
| cbebd19ab3 | |||
| 05764cac15 | |||
| 0a6b0171d8 | |||
| 43486afec4 | |||
| 225d5c0777 | |||
| fbf326d525 | |||
| 28c3c8d8ab | |||
| 571c135114 | |||
| d840745758 | |||
| cf3f0ae990 | |||
| a11cdf4e7e | |||
| 81096051dd | |||
| f221f81d4e | |||
| 3738f0bea2 | |||
| 6ebc570632 | |||
| 2d02ccff5d | |||
| 25b0c62dea | |||
| ff75aefcdd | |||
| 49e4d6e9a6 | |||
| b27dd5b5ba | |||
| 9fa5953713 | |||
| db2325dbe4 | |||
| 16ee121ab3 | |||
| 139d176841 | |||
| 0b6bf0b2fa | |||
| 51be1517c0 | |||
| 98f057e26b | |||
| 8878a90315 | |||
| 2ecb1172f4 | |||
| d2c8c3240c | |||
| 2c9f610a8e | |||
| 2c9bf56054 | |||
| 633ecff4e2 | |||
| 46bc573777 | |||
| 0002e86729 | |||
| caf0191366 | |||
| ba284922e5 | |||
| 7cf139dfd2 | |||
| 6c1ffc73d3 |
21
.env.example
21
.env.example
@@ -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
|
||||
|
||||
300
CHANGELOG.md
300
CHANGELOG.md
@@ -9,6 +9,306 @@
|
||||
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
|
||||
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
|
||||
|
||||
## 1.0.113
|
||||
|
||||
- 固化数据管理契约:`visible=0` 只藏界面;`default_sort_json` 仍可引用隐藏的非敏感物理列(含主键)
|
||||
- 元数据校验:排序列必须真实存在;引用隐藏列仅提示、不报错
|
||||
|
||||
## 1.0.112
|
||||
|
||||
- 新技能的名字不必再以平台或 `default` 结尾。没有抖音、小红书、亚马逊这类外部平台时,用动词加名词即可
|
||||
- 不要再把 `default` 当作占位平台。已经发布、名字末尾是 `default` 的技能不用改名
|
||||
|
||||
## 1.0.111
|
||||
|
||||
- 沉淀数据管理呈现加法:同一 `cellPresentation.enabled` 支持单元格、整行、整列、行列交叉区域(闭集 tone)
|
||||
- 新增 `_jiangchang_grid_presentation` 契约 / Schema / 示例;v1 `cells[]` 保持不变;禁止整表 `all`×`all`;整行色只涂表格、不铺到详情字段
|
||||
- 内部隐藏表补上两张呈现旁路表;模板默认仍不启用着色
|
||||
|
||||
## 1.0.110
|
||||
|
||||
- 数据管理侧栏除了生成出来的 HTML 快照,也会直接显示安装包里 `assets/html/pages/` 的页面,装完就有,不用点生成
|
||||
- 这些页面可以调用本技能已经声明的命令:宿主代为执行 Python,把 JSON 交回页面
|
||||
|
||||
## 1.0.109
|
||||
|
||||
- 沉淀任务中断与账号租约契约:禁止用裸 `except Exception` 吞掉 `JobStopped`;`finally` 先释租约再关浏览器;宿主只通知不代收租约
|
||||
- 补充 CHECKLIST A5-13(中断后租约须释放)及 ACCOUNT_MANAGER / LOGGING / ADAPTER / 样例 README
|
||||
|
||||
## 1.0.108
|
||||
|
||||
- 沉淀数据管理「用户可见文案」契约:弹窗 description / 确认黄条 / 参数说明 / 任务失败 message 须全中文产品用语
|
||||
- 明确禁止把字段名、CLI 子命令、async/pending、Agent 禁令写进用户可见文案;Agent 路由仍放 SKILL.md
|
||||
- 修正 `selection-mode-optional` 示例为口语文案;更新 ACTIONS / RUNTIME / CHECKLIST A4-10
|
||||
|
||||
## 1.0.107
|
||||
|
||||
- 沉淀数据管理 `bind.selectionMode` 契约(`required` / `none` / `optional`):同一按钮可「有勾选走 ids、无勾选走 pickCount」
|
||||
- 明确省略时的宿主兼容推断、空选数量字段名约定,以及勿再拆同义双按钮
|
||||
- 更新 ACTIONS.md / Schema / SKILL_ACTION_RUNTIME / CHECKLIST A4-9;新增 `selection-mode-optional.action.example.json`
|
||||
|
||||
## 1.0.106
|
||||
|
||||
- 文档契约:补充可选 `metadata.openclaw.brands`(OEM 市场编码 → `skill_market_relations`)
|
||||
- 明确未写则仅挂 `jiangchang`;与 `developer_ids` 分工;勿用 Gitea 组织名推断市场
|
||||
- 更新 `SKILL.md`、`DEVELOPMENT.md` §6、`development/README.md`、`REQUIREMENTS.md` 与发布检查清单
|
||||
|
||||
## 1.0.105
|
||||
|
||||
- 沉淀数据管理 form 与「已选择」摘要对齐契约:映射 `readOnly` 字段默认不占参数区(仍提交),与 confirm 信息架构一致
|
||||
- 新增可选 `revealInForm` 逃生阀;明确禁止用 `visibleWhen` 藏映射主键
|
||||
- 更新 ACTIONS.md / Schema / SKILL_ACTION_RUNTIME / CHECKLIST A4-8
|
||||
|
||||
## 1.0.104
|
||||
|
||||
- 新增发布契约 POLICY-RELEASE-001:`release.ps1` 打 tag 前强制校验 Next tag = `SKILL.md` version = `SKILL_VERSION`,避免市场一直显示「更新」
|
||||
- 日常测试增加 `SKILL.md` 与 `SKILL_VERSION` 对齐;更新 DEVELOPMENT / POLICY_MATRIX / CHECKLIST A9-4
|
||||
|
||||
## 1.0.103
|
||||
|
||||
- 用户市场文档:说明/教程正文禁止与宿主页头、Tab 重复的文首一级标题;主分节统一用 `##`
|
||||
- 更新 `TUTORIAL.md` / `README.md` 模板骨架、`TUTORIAL_SURFACES.md` §0.1、CHECKLIST A2-12 与契约测试
|
||||
|
||||
## 1.0.102
|
||||
|
||||
- 沉淀数据管理 Action「已选择」摘要契约:confirm/form 弹层由宿主默认展示勾选对象(#id + 可读标签);技能零改动即可用
|
||||
- 新增可选 `bind.labelColumn`(仅影响展示,不改提交 payload,不决定业务跳过逻辑)
|
||||
- 更新 `skill-actions.schema.json`、`ACTIONS.md`、`SKILL_ACTION_RUNTIME.md`;新增 `selection-label-column.action.example.json`
|
||||
|
||||
## 1.0.101
|
||||
|
||||
- 沉淀 Action 表单本地路径契约:`file` / `directory` 可选 **`pathRoot`**(相对技能数据根的起始目录;省略则宿主默认打开本技能 `input/`)
|
||||
- 更新 `skill-actions.schema.json`、`ACTIONS.md`、`DATA_PATHS.md`;新增 `assets/examples/path-root-file-action.example.json`
|
||||
- 与宿主导入面板默认 `input/` 划清边界;禁止技能写死用户 ID 或绝对盘符
|
||||
|
||||
## 1.0.100
|
||||
|
||||
- 沉淀用户市场文档契约 `development/TUTORIAL_SURFACES.md`:**说明 Tab ≠ 教程 Tab**;教程按数据管理 ∥ 对话、定时可选三入口结构
|
||||
- 重写根 `README.md` / `TUTORIAL.md` 模板骨架;CHECKLIST 增 A2-7~A2-11;`actions.json` 准备类优先 toolbar、skill-detail 不可作教程主路径
|
||||
- 新增 `tests/test_tutorial_surfaces.py`(POLICY-TUTORIAL-001);REQUIREMENTS §0.1 增教程章节列与用户文档对齐要求
|
||||
|
||||
## 1.0.99
|
||||
|
||||
- 沉淀数据管理**表注册门禁**契约:存在 `_jiangchang_tables` 时,宿主仅展示注册且 `visible=1` 的表;孤儿物理表不展示(匠厂宿主 ≥6.8.25)
|
||||
- 新增「表增删改与退役」:`drop_deprecated_user_table()` 标准迁移;禁止只删元数据留物理表;新技能禁止 `task_logs`
|
||||
- `LOGGING.md` 可观测性三分法;`DATA_PATHS.md` 卸载不删用户数据;Checklist A6-13 / A6-14;`tests/samples/test_table_deprecation_migration.py.sample`
|
||||
|
||||
## 1.0.98
|
||||
|
||||
- 沉淀数据管理**列筛选与全局搜索**契约:`searchable` 同时控制列筛选字段列表与顶栏全局搜索;存库 value / 展示 label 分工;筛选栏枚举/boolean/lookup 用下拉 Select(与表单开关/分段单选区分)
|
||||
- 全局搜索:options label 映射、boolean 别名、lookup 多选 token、`datetime_unix_seconds` 日期区间
|
||||
- `ACTIONS.md` 表单对照表补充「列筛选」脚注;`CHECKLIST` A6-11 / A6-12;`display_metadata_validator` 对未开 searchable 的枚举/boolean 列软 warning
|
||||
|
||||
## 1.0.97
|
||||
|
||||
- 沉淀数据管理 HTML 预览**布局契约**:`references/ACTIONS.md` 新增「宿主预览布局」(流体铺满 iframe、禁横向滚动、表格断行等)
|
||||
- 升级 `assets/html/example-report.html` 为通用布局基线;`test_html_page_service` 守护不退化
|
||||
- `DEVELOPMENT.md` / `CHECKLIST`(S0-12、A6-10)/ `TESTING.md` 交叉引用
|
||||
|
||||
## 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
|
||||
|
||||
10
DEMO.md
10
DEMO.md
@@ -15,9 +15,13 @@ demo_video_url: ""
|
||||
|
||||
## 建议观看顺序
|
||||
|
||||
1. 如何发起一次典型任务
|
||||
2. 执行过程中会看到什么提示 / 进度
|
||||
3. 成功后的结果长什么样
|
||||
建议与 `TUTORIAL.md` 三入口结构对齐(有则拍、无则跳过):
|
||||
|
||||
1. 数据管理:准备数据 → 主流程按钮 → 结果表/报告
|
||||
2. 对话任务(若有):一句自然语言触发同一能力
|
||||
3. 定时任务(若有):到点自动跑;若本技能无定时,演示中可说明「不需要定时」
|
||||
|
||||
成功后的结果长什么样(表、任务中心、HTML 等)
|
||||
|
||||
## 没有视频时
|
||||
|
||||
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
63
README.md
63
README.md
@@ -1,14 +1,14 @@
|
||||
---
|
||||
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及如何开始使用。"
|
||||
description: "用用户能理解的方式说明这个技能能做什么、适合什么场景,以及边界与结果形态。"
|
||||
---
|
||||
|
||||
> **模板说明(复制后删除本段)**
|
||||
> 本文是技能市场详情与普通用户说明,**不是** Agent 参考索引、CLI 契约或开发文档。
|
||||
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约、数据结构、开发规范或 Agent 参考索引。
|
||||
> 正文只回答用户关心的问题,**不要**把 Playwright、DOM、Python、RPA、references、development、schema 等作为面向用户的主说明内容;若业务上必须出现技术词,须用用户能理解的方式解释,且不能喧宾夺主。
|
||||
> **复制后验收章节(标题可微调,建议保留):**「能帮你做什么」「适合什么场景」「使用前需要准备什么」「你可以这样告诉 Agent」「执行后你会得到什么」「注意事项」「常见问题」。
|
||||
|
||||
# 【技能名称】
|
||||
> 本文是技能市场 **「说明」** Tab(`readme_md`),**不是**使用教程。
|
||||
> frontmatter 的 `description` 必须是用户能看懂的一句话能力描述;**不得**写成 CLI、schema、references、development、契约或「如何开始使用」类教程承诺。
|
||||
> **禁止**写入:分步操作(1/2/3)、Agent 示例话术块、「入口一/入口二」专章、逐步排障。这些一律写在根目录 `TUTORIAL.md`。
|
||||
> 正文只回答:是什么、能做什么、适合谁、准备什么、得到什么、注意事项。完整契约见 `development/TUTORIAL_SURFACES.md` §1。
|
||||
> **复制后验收章节(标题可微调):**「能帮你做什么」「适合什么场景」「使用前需要准备什么」「怎么使用」「执行后你会得到什么」「注意事项」「常见问题」。
|
||||
> **标题层级:** 宿主页头已有技能名——**禁止**文首再写 `# 技能名`;正文主分节用 `##`。
|
||||
|
||||
> 用一两句话说明这个技能帮助用户完成什么工作。
|
||||
> 这里不要解释技术实现,要从用户的业务目标出发描述价值。
|
||||
@@ -17,7 +17,7 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
||||
|
||||
## 这个技能能帮你做什么
|
||||
|
||||
用 3 到 6 条业务能力说明技能价值,例如:
|
||||
用 3 到 6 条业务能力说明技能价值(**不要**写成操作步骤),例如:
|
||||
|
||||
- 【自动整理/查询/生成/同步/核对/提交】某类业务数据
|
||||
- 帮你减少重复操作,降低人工录入或核对成本
|
||||
@@ -38,42 +38,33 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
||||
|
||||
## 使用前需要准备什么
|
||||
|
||||
说明用户在使用前要准备的东西,例如:
|
||||
说明**前置条件**(装什么、要不要账号、要不要外站登录),**不要**写「去哪张表点哪个按钮」:
|
||||
|
||||
- **网页自动化类:** 首次运行可由技能自动登记平台账号(浏览器会话由系统隔离管理)。也可在「账号管理」里指定登录标识。登记账号 ≠ 已登录目标站
|
||||
- **账号在哪里看:** 已登记账号可在「数据管理 / 账号管理」中查看;每个账号使用独立浏览器会话,批量或多账号任务不会共用登录状态
|
||||
- **是否要登录目标站:** 按本技能说明(有的平台必须登录,有的公开页可不登录);与「是否有账号记录」不是一回事
|
||||
- **网页自动化类:** 可能需要在账号管理中登记平台账号;登记账号 ≠ 已登录目标站(细节见使用教程)
|
||||
- 相关业务权限(查询、导出、提交等)已具备
|
||||
- 需要处理的文件、时间范围、订单号、客户名称、项目名称等信息
|
||||
- 如果涉及验证码或人工确认,请保证能及时在弹出的浏览器窗口中操作
|
||||
- 需要处理的文件、时间范围、单号等信息(若适用)
|
||||
- 若涉及验证码或人工确认,使用时请能及时在弹出窗口中操作
|
||||
|
||||
---
|
||||
|
||||
## 你可以这样告诉 Agent
|
||||
## 怎么使用
|
||||
|
||||
用自然语言示例展示用户可以怎么发起任务,例如:
|
||||
本技能支持哪些宿主入口,用**一两句话**概括即可,例如:
|
||||
|
||||
> “帮我查询今天的【业务对象】,整理成表格。”
|
||||
>
|
||||
> “帮我核对这些【数据/单据】是否有异常,并告诉我问题在哪里。”
|
||||
>
|
||||
> “帮我把【时间范围】内的【业务数据】导出并生成处理报告。”
|
||||
>
|
||||
> “帮我根据这个文件里的内容,逐条完成【业务动作】。”
|
||||
- 支持 **数据管理**(点表操作)与 **对话任务**(自然语言);是否提供定时任务按本技能说明。
|
||||
|
||||
> 注意:示例应是自然语言,不要写命令行命令。
|
||||
**逐步操作、示例话术与排障,请查看技能市场「使用教程」Tab。**
|
||||
(完整写法见 `development/TUTORIAL_SURFACES.md`。)
|
||||
|
||||
---
|
||||
|
||||
## 执行后你会得到什么
|
||||
|
||||
说明用户会看到什么结果,例如:
|
||||
说明用户会看到什么**结果形态**(不要教如何点出来),例如:
|
||||
|
||||
- 任务是否成功完成
|
||||
- 处理了多少条数据
|
||||
- 哪些内容成功、哪些失败
|
||||
- 失败原因和建议处理方式
|
||||
- 生成的文件、表格、报告或记录
|
||||
- 处理了多少条数据;哪些成功、哪些失败
|
||||
- 生成的文件、表格、报告或业务表记录
|
||||
|
||||
---
|
||||
|
||||
@@ -90,18 +81,16 @@ description: "用用户能理解的方式说明这个技能能做什么、适合
|
||||
|
||||
## 常见问题
|
||||
|
||||
### 为什么任务没有查到数据?
|
||||
> FAQ 写**概念与边界**;需要「点哪里」的排障写到「使用教程」。
|
||||
|
||||
> 可能是时间范围、关键词、账号权限或系统筛选条件不正确。请先确认输入信息是否准确。
|
||||
### 这个技能适合我吗?
|
||||
|
||||
### 为什么任务执行到一半需要我操作?
|
||||
> 若你的场景与上文「适合什么场景」一致,可以安装后按「使用教程」完成第一次操作。
|
||||
|
||||
> 可能遇到了登录、验证码、权限确认、二次审批或系统异常。请根据 Agent 的提示完成必要操作。
|
||||
### 我不会操作,从哪里开始?
|
||||
|
||||
### 结果文件在哪里查看?
|
||||
|
||||
> 请根据 Agent 返回的结果说明查看生成文件、任务记录或处理报告。
|
||||
> 请打开技能市场 **「使用教程」** Tab,按「入口一:数据管理」或「入口二:对话任务」任选一种跟做。
|
||||
|
||||
### 已经登记的账号在哪里查看?
|
||||
|
||||
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。若任务仍提示账号异常,请把账号编号、任务时间和界面提示原文提供给支持人员;不要发送密码、Cookie 或验证码。
|
||||
> 请到匠厂的「数据管理 / 账号管理」查看账号记录和状态。不要发送密码、Cookie 或验证码。
|
||||
|
||||
20
SECURITY.md
Normal file
20
SECURITY.md
Normal 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/`,不要写进本文件。
|
||||
49
SKILL.md
49
SKILL.md
@@ -1,17 +1,19 @@
|
||||
---
|
||||
name: 技能开发模板(通用业务版)
|
||||
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
|
||||
version: 1.0.60
|
||||
version: 1.0.113
|
||||
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
|
||||
# brands: # 可选;未写则发布后仅挂匠厂市场(jiangchang)
|
||||
# - jiangchang # 匠厂主站
|
||||
# - huashang # 华商等 OEM:按目标市场填写,可多选
|
||||
allowed-tools:
|
||||
- bash
|
||||
---
|
||||
@@ -31,18 +33,25 @@ allowed-tools:
|
||||
|
||||
### 架构铁律(先读)
|
||||
|
||||
- **宿主产品入口平级**:对话任务 / 数据管理 / 定时任务是同一技能能力的不同触发场景,**同等重要**;挂哪些由本技能 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` 时**必须**显式非空绑定。
|
||||
- **`bind.selectionMode`(可选)**:表勾选如何驱动 Action(`required` / `none` / `optional`);省略由宿主推断。同一能力勿拆两个同义顶栏按钮,见 `references/ACTIONS.md`。
|
||||
- **用户可见文案**:数据管理弹窗 / 任务中心文案须全中文产品用语;勿把 `pickCount`、`async`、`pending`、CLI 子命令写进弹窗(见 `references/ACTIONS.md`「用户可见文案」)。
|
||||
- **`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**:壳子页放 `assets/html/pages/`,快照写 `html/` 并用固定 Action id `render-html`;页面用 `jiangchang.call` 调已声明命令(见 `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 +73,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 +91,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` 做只读诊断。
|
||||
@@ -104,10 +114,11 @@ python {baseDir}/scripts/main.py init-db
|
||||
|
||||
**回答「怎么用 / 能做什么」时的要求:**
|
||||
|
||||
1. 「能做什么 / 场景 / FAQ」以 `README.md` 为主;「第一次怎么用完」以 `TUTORIAL.md` 为主。
|
||||
1. 「能做什么 / 场景 / 概念类 FAQ」以 `README.md`(说明 Tab)为主;「点哪里 / 说什么 / 排障」以 `TUTORIAL.md`(教程 Tab)为主。说明 Tab **不应**含分步操作;若 README 仍有旧式步骤,以教程为准并提示开发者按 `development/TUTORIAL_SURFACES.md` 修正。
|
||||
2. **禁止**在回答中粘贴 `python …/scripts/main.py …` 或其它命令行(除非用户明确索要命令,或自称技术人员/开发者)。
|
||||
3. **禁止**为答用法而去 `dir` / `ls` / 列技能目录,或默认打开 `references/`、`development/`;仅当用户明确问命令、参数、集成或排错时再读 `references/`。
|
||||
4. **不要**向普通用户暴露 Playwright、DOM、Python、RPA、库表名、`error_code` 等实现细节,除非用户明确询问技术实现。
|
||||
5. 用户问「怎么用」时:**优先读 `TUTORIAL.md`**;不要只根据 README 臆造按钮路径。
|
||||
|
||||
**文档读取边界:**
|
||||
|
||||
@@ -120,13 +131,13 @@ python {baseDir}/scripts/main.py init-db
|
||||
|
||||
| 文档 | 读者 | 用途 |
|
||||
|------|------|------|
|
||||
| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab(`readme_md`) |
|
||||
| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab(`tutorial_md`) |
|
||||
| 根目录 `README.md` | 普通用户 | 市场 **说明** Tab(`readme_md`):能力/场景/边界;**非教程** |
|
||||
| 根目录 `TUTORIAL.md` | 普通用户 | 市场 **教程** Tab(`tutorial_md`):分入口操作;结构见 `development/TUTORIAL_SURFACES.md` |
|
||||
| 根目录 `DEMO.md` | 普通用户 | 市场 **演示** Tab(`demo_video_url` + `demo_md`) |
|
||||
| 根目录 `CHANGELOG.md` | 普通用户 | 市场 **更新日志** Tab(按版本小节 → `changelog`) |
|
||||
| `SKILL.md`(本文) | LLM / OpenClaw 平台 | 技能入口、触发与运行契约摘要 |
|
||||
| `references/` | Agent 编排 / 调用 | CLI、Action、schema 契约(Agent 主读) |
|
||||
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范 |
|
||||
| `development/` | 开发者 / AI 编程代理 | 需求、开发教程、测试、深度技术规范;**产品入口**见 `development/PRODUCT_SURFACES.md`;**用户文档契约**见 `development/TUTORIAL_SURFACES.md` |
|
||||
|
||||
## 目录约定
|
||||
|
||||
@@ -141,14 +152,14 @@ 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`。
|
||||
|
||||
## 技能命名与 slug
|
||||
|
||||
- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。
|
||||
- 标准格式:`{verb}-{noun-phrase}`,绑定外部平台时才加 `-{platform}`(详见 `development/NAMING.md`)。不要用 `default` 占位。
|
||||
- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。
|
||||
- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。
|
||||
- `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。
|
||||
@@ -161,3 +172,9 @@ python {baseDir}/scripts/main.py init-db
|
||||
- 开发期技能常为不公开:不加本人 ID,开发者自己也无法在市场安装自测。取 ID 与写入步骤见 `development/DEVELOPMENT.md` §6。
|
||||
- 当 `access_scope = 0`(不公开)时,平台会把 `developer_ids` 中的用户自动补写到 `skill_user_access`。
|
||||
- `developer_ids` 建议写为正整数数组;第一个 ID 会作为主开发者同步到 `skills.developer_id`。
|
||||
- `metadata.openclaw.brands`(**可选**):OEM 技能市场编码列表,写入平台 `skill_market_relations.market_code`。兼容别名 `oem_clients` / `client_keys`。
|
||||
- **未写或为空**:发布时仅幂等补默认 `jiangchang`(匠厂主站),兼容老技能。
|
||||
- **显式列表**(如 `jiangchang`、`huashang`):发布时幂等补写对应市场(**只补不删**);客户端靠 Header `X-Market-Code` 过滤目录。
|
||||
- **不是** Gitea 组织名推断:仓库在 `client-huashang` 下 ≠ 自动出现在华商市场,必须以 `brands` 声明。
|
||||
- 与 `developer_ids` 分工:`brands` 管「进哪个 OEM 市场目录」;`developer_ids` 管「不公开时谁能看见/安装」。
|
||||
- 完整步骤与验收见 `development/DEVELOPMENT.md` §6「关于 brands」。
|
||||
|
||||
143
TUTORIAL.md
143
TUTORIAL.md
@@ -1,12 +1,33 @@
|
||||
> **模板说明(复制后删除本段)**
|
||||
> 本文对应技能市场 **「使用教程」** Tab(发布时同步为 `tutorial_md`)。
|
||||
> 面向普通用户写**可跟着做的步骤**,不要写 CLI、目录结构、release、开发规范。
|
||||
> 与根 `README.md`(技能说明)分工:README 讲「是什么 / 能做什么」;本文讲「第一次怎么用完」。
|
||||
> **复制后验收章节(标题可微调):**「开始前」「第一步…」「第二步…」「常见卡住怎么办」。
|
||||
> 与根 `README.md`(技能说明)分工:README 讲「是什么 / 能做什么 / 边界」;本文讲「从哪个入口怎么用完」。
|
||||
> **完整契约:** `development/TUTORIAL_SURFACES.md`(三入口结构、说明≠教程、skill-detail 不可作主路径)。
|
||||
> **复制后验收章节(标题可微调,语义不可缺):**「本技能做什么」「开始前」「入口一:数据管理」「入口二:对话任务」「入口三:定时任务」「主能力对照表」「常见卡住怎么办」。
|
||||
> 若本技能 §0.1 未挂数据管理 / 对话 / 定时,对应入口节改为「不适用」并写一句理由(与 REQUIREMENTS 一致)。
|
||||
> **标题层级:** 宿主页头已有技能名、Tab 已有「使用教程」——**禁止**文首再写 `# 技能名 · 使用教程`;正文主分节用 `##`,步骤用 `###`。
|
||||
|
||||
# 【技能名称】使用教程
|
||||
本技能有两种**平行**的使用方式(按本技能入口定位填写;未挂的入口删掉或写不适用):
|
||||
|
||||
用最短路径带用户完成一次成功使用。步骤要具体,可用「打开匠厂 → …」这类操作语言。
|
||||
- **数据管理**:在左侧表上点工具栏 / 行内按钮
|
||||
- **对话任务**:用自然语言让 Agent 完成同一能力
|
||||
|
||||
两种方式触发同一套业务能力,结果一致,选你习惯的即可。
|
||||
定时任务是否提供:见下方「入口三」(没有则写「本技能不需要定时」+ 理由)。
|
||||
|
||||
---
|
||||
|
||||
## 本技能做什么
|
||||
|
||||
用业务语言画清闭环(**不要**写库表英文名或命令行),例如:
|
||||
|
||||
```text
|
||||
【输入/准备】 → 【核心动作】 → 【结果可见处】 → 【可选报告/导出】
|
||||
```
|
||||
|
||||
- **输入**:……
|
||||
- **输出**:……
|
||||
- **说明**:边界(教学/正式、是否接外站等)
|
||||
|
||||
---
|
||||
|
||||
@@ -14,43 +35,105 @@
|
||||
|
||||
确认:
|
||||
|
||||
1. 已安装并登录匠厂客户端
|
||||
2. 已在技能市场安装本技能(或更新到最新版)
|
||||
3. 【网页自动化类技能】首次运行一般会自动登记本平台账号;也可在「账号管理」中指定登录标识。浏览器会话由系统隔离管理——**不要**用本机日常浏览器用户目录去跑真实站
|
||||
4. 【按平台】若本技能说明「需要先登录目标站」,请在弹出的有头浏览器中按提示登录或扫码;「登记账号」不等于「已登录目标站」。若说明「可不登录也能用」,仍须有可用的账号会话目录
|
||||
5. 【网页自动化类技能】已登记账号可在「数据管理 / 账号管理」查看;每个账号都有独立浏览器会话
|
||||
6. 【如需】需要的文件 / 时间范围 / 单号等已准备好
|
||||
1. 已安装并登录匠厂客户端
|
||||
2. 已在技能市场安装本技能(建议更新到最新版)
|
||||
3. 【网页自动化类】账号登记 / 目标站登录要求(按本技能;登记 ≠ 已登录)
|
||||
4. 【如需】文件 / 时间范围 / 单号等已准备好
|
||||
5. 【如需】首次须先完成「载入样例 / 导入 / 同步」等准备动作(见入口一或入口二)
|
||||
|
||||
---
|
||||
|
||||
## 第一步:打开技能并说清目标
|
||||
## 入口一:数据管理
|
||||
|
||||
在匠厂里新建任务或打开对话,用自然语言说明要做什么,例如:
|
||||
> 若本技能无业务表、无 toolbar/row/batch:整节改为「不适用」+ 理由。
|
||||
|
||||
> “帮我【完成某件业务事】,范围是【时间 / 对象 / 条件】。”
|
||||
适合:对照数据、喜欢点界面、机房/运营用户。
|
||||
|
||||
建议一次只交代一件事;范围说清楚,减少来回确认。
|
||||
### 左侧各表是干什么的
|
||||
|
||||
| 表名(中文) | 作用 |
|
||||
|--------------|------|
|
||||
| 【表 A】 | …… |
|
||||
| 【表 B】 | …… |
|
||||
|
||||
### 首次准备(若需要)
|
||||
|
||||
1. 进入 **数据管理** → **【主表】**
|
||||
2. 点击表顶 **「【准备类按钮 label】」**(若为空表可点,写明是否须选中行)
|
||||
3. 成功后应看到:……
|
||||
|
||||
> **注意:** 准备/载入/导入类按钮须挂在数据管理 **toolbar**(用户看得见)。不要把「技能详情页点按钮」写成主路径——详情页常只显示四 Tab,不一定渲染 Action 按钮。
|
||||
|
||||
### 主流程
|
||||
|
||||
1. 进入 **【主表】**
|
||||
2. 【是否选中行】
|
||||
3. 点击 **「【主能力 label】」**
|
||||
4. 到 **【结果表 / 任务中心 / 报告】** 核对结果
|
||||
|
||||
### 行级 / 批量(若有)
|
||||
|
||||
| 你想做什么 | 操作 |
|
||||
|------------|------|
|
||||
| …… | **【表】** 行内 → **「【label】」** |
|
||||
| …… | 勾选多行 → **「【label】」** |
|
||||
|
||||
---
|
||||
|
||||
## 第二步:按提示补充信息
|
||||
## 入口二:对话任务
|
||||
|
||||
如果 Agent 问你缺什么(账号、文件、筛选条件),按提示补充即可。
|
||||
> 若本技能不挂 `agent`:整节改为「不适用」+ 理由。
|
||||
|
||||
- 有文件时:按界面提示选择或拖入
|
||||
- 有账号时:确认已在对应平台登录,且具备操作权限
|
||||
适合:临时提问、要解释、不想切换表。
|
||||
|
||||
### 什么时候用对话
|
||||
|
||||
- ……
|
||||
- 与入口一效果相同;习惯看表时优先用数据管理
|
||||
|
||||
### 示例话术
|
||||
|
||||
| 你想做什么 | 可以这样说 |
|
||||
|------------|------------|
|
||||
| …… | 「……」 |
|
||||
| …… | 「……」 |
|
||||
|
||||
### Agent 可能追问什么
|
||||
|
||||
- 缺参数时:……
|
||||
- 失败时:可改用数据管理点按钮
|
||||
|
||||
---
|
||||
|
||||
## 第三步:等待结果并核对
|
||||
## 入口三:定时任务
|
||||
|
||||
任务结束后,请核对:
|
||||
> **有 `cron`:** 写如何新建定时、选哪个 Action、默认参数、预期结果。
|
||||
> **无 `cron`:** 用下面「不需要」模板,删掉「如何配置」小节。
|
||||
|
||||
- 是否成功完成
|
||||
- 处理了多少条 / 哪些失败
|
||||
- 生成的文件、表格或记录是否在预期位置
|
||||
### 本技能不需要定时时(示例文案,按需保留/改写)
|
||||
|
||||
若失败,先看失败原因说明,再按下一节排查。
|
||||
本技能**未提供定时任务入口**,原因:【一次性 / 须人指定对象 / 须当场确认 / ……】。
|
||||
请使用 **数据管理** 或 **对话任务**。
|
||||
|
||||
### 本技能需要定时时(示例结构)
|
||||
|
||||
1. 打开侧栏 **定时任务** → 新建
|
||||
2. 选择本技能 → Action **「【label】」**
|
||||
3. 填写参数 → 保存;可「立即运行」验证
|
||||
4. 到点后在【结果表 / 任务中心】查看
|
||||
|
||||
---
|
||||
|
||||
## 主能力对照表
|
||||
|
||||
同一能力,两种主入口(按本技能实际填写;只挂一端的注明):
|
||||
|
||||
| 想做什么 | 数据管理 | 对话任务 |
|
||||
|----------|----------|----------|
|
||||
| …… | **【表】** → **「【label】」** | 「……」 |
|
||||
| …… | …… | …… |
|
||||
|
||||
按钮名必须与 `assets/actions.json` 的 `label` 一致。
|
||||
|
||||
---
|
||||
|
||||
@@ -58,10 +141,10 @@
|
||||
|
||||
| 现象 | 你可以先试 |
|
||||
|------|------------|
|
||||
| 提示未登录 / 无权限 | 确认匠厂已登录;业务账号是否在线、权限是否足够 |
|
||||
| 提示账号不存在 / 被占用 | 到「数据管理 / 账号管理」确认账号记录和状态,稍后再试;不要重复创建账号 |
|
||||
| 提示缺少必要信息 | 把时间范围、单号、文件等说完整后再试一次 |
|
||||
| 一直没有结果 | 到任务中心查看进度或失败原因;必要时重新发起 |
|
||||
| 结果不对 | 用更明确的条件再说一次,或缩小范围重试 |
|
||||
| 主表是空的 | 先做「首次准备」或对话载入/导入 |
|
||||
| 按钮点不了 | 是否须先选中行;是否装错版本 |
|
||||
| 一直没有结果 | 看任务中心 / 运行日志;async 任务看进度 |
|
||||
| 【网页自动化】未登录 / 无权限 | 确认账号在线与目标站登录状态 |
|
||||
| 结果不对 | 缩小范围重试,或改用数据管理核对表数据 |
|
||||
|
||||
仍无法解决时,把「你说了什么 + 界面提示原文」发给支持人员,便于排查。
|
||||
仍无法解决时,把「你用的入口 + 操作步骤 + 界面提示原文」发给支持人员。
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
# 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` 示例。
|
||||
- **业务技能注意:** 若主路径在数据管理,准备/载入/导入类 Action 应挂 `toolbar` + `bind.tables`(用户看得见),**不要**只挂 `skill-detail` 却在教程里写成「技能详情点按钮」。见 `development/TUTORIAL_SURFACES.md` §5、`references/ACTIONS.md`「用户可见性」。
|
||||
- `html/example-report.html`:HTML 快照页最小壳(`{{TITLE}}` / `{{GENERATED_AT}}` / `{{BODY}}`);含数据管理 iframe 预览布局基线(流体铺满、禁横向滚动);渲染后须写入数据目录 `html/`。不要放进 `pages/`。
|
||||
- `html/pages/*.html`:装完就出现在侧栏的壳子页(宿主直接读安装包,不经生成)。页面用 `jiangchang.call` 调本技能已声明的命令。见 `references/ACTIONS.md`「HTML 页面报告」。模板仓库不要在这里放示例页。
|
||||
- `examples/`:CLI 成功输出形状示例、Action 形状参考(含 `path-root-file-action.example.json`、`selection-label-column.action.example.json`、`selection-mode-optional.action.example.json`、`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` / `columnMapping` / **`labelColumn`**(选择摘要标签列)/ **`selectionMode`**(`required` \| `none` \| `optional`:表勾选如何驱动 Action;省略则宿主兼容推断)。placements 含 `toolbar` / `row` / `batch` / `cron` / `agent` / `skill-detail`(对齐匠厂宿主)。数据管理 confirm/form 弹层在会应用勾选时展示「已选择」摘要;**form + 有摘要时映射 `readOnly` 默认不占参数区(仍提交)**,可选 **`revealInForm`** 强制显示。弹窗 `description` / 确认黄条 / 参数说明须**用户可见中文**(勿写 `pickCount`/`async`/`pending`/CLI 子命令;见 ACTIONS.md「用户可见文案」)。可选 `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)
|
||||
- 用户市场四 Tab 见根目录 [`README.md`](../README.md) / [`TUTORIAL.md`](../TUTORIAL.md) / [`DEMO.md`](../DEMO.md) / [`CHANGELOG.md`](../CHANGELOG.md);写法契约见 [`development/TUTORIAL_SURFACES.md`](../development/TUTORIAL_SURFACES.md)
|
||||
- Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md))
|
||||
- 开发规范见 [`development/`](../development/)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
# 示例 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`)。
|
||||
- `skill-grid-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)。
|
||||
- `selection-label-column.action.example.json`:选择摘要 `bind.labelColumn` 形状参考(见 `references/ACTIONS.md`「选择摘要」)。
|
||||
- `selection-mode-optional.action.example.json`:`bind.selectionMode: "optional"` 智能勾选/按数量合并形状参考;用户可见文案为口语中文(见 `references/ACTIONS.md`「勾选驱动模式」「用户可见文案」)。
|
||||
- `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`」)。
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
83
assets/examples/lookup-action.example.json
Normal file
83
assets/examples/lookup-action.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
36
assets/examples/path-root-file-action.example.json
Normal file
36
assets/examples/path-root-file-action.example.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"_comment": "形状参考:Action 表单 file/directory 用 pathRoot 指定原生对话框起始目录(相对技能数据根)。省略则宿主默认 {skill_data}/input。完整契约见 references/ACTIONS.md「本地路径选择与 pathRoot」。勿与宿主导入面板(replaces:import)混用本示例。",
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "import-pack",
|
||||
"label": "导入数据包",
|
||||
"description": "从本技能输入目录子文件夹选择 JSON 并写入业务表。",
|
||||
"placements": ["toolbar", "agent"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "form",
|
||||
"bind": { "tables": ["business_items"] },
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["file"],
|
||||
"properties": {
|
||||
"file": {
|
||||
"type": "string",
|
||||
"title": "数据包文件",
|
||||
"description": "请选择 input/数据包/ 下的 JSON。",
|
||||
"format": "file",
|
||||
"display_type": "file",
|
||||
"pathRoot": "input/数据包"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "import-pack",
|
||||
"args": ["--file", "{{file}}"]
|
||||
},
|
||||
"steps": [{ "when": "afterSuccess", "do": "reloadTable" }]
|
||||
}
|
||||
]
|
||||
}
|
||||
28
assets/examples/render-html.action.example.json
Normal file
28
assets/examples/render-html.action.example.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
28
assets/examples/replaces-create.action.example.json
Normal file
28
assets/examples/replaces-create.action.example.json
Normal 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}}"]
|
||||
}
|
||||
}
|
||||
33
assets/examples/replaces-delete.action.example.json
Normal file
33
assets/examples/replaces-delete.action.example.json
Normal 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}}"]
|
||||
}
|
||||
}
|
||||
38
assets/examples/replaces-import.action.example.json
Normal file
38
assets/examples/replaces-import.action.example.json
Normal 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}}"]
|
||||
}
|
||||
}
|
||||
34
assets/examples/replaces-update.action.example.json
Normal file
34
assets/examples/replaces-update.action.example.json
Normal 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}}"]
|
||||
}
|
||||
}
|
||||
38
assets/examples/selection-label-column.action.example.json
Normal file
38
assets/examples/selection-label-column.action.example.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"id": "process-selected",
|
||||
"label": "处理勾选",
|
||||
"description": "对勾选行执行业务处理。示例:声明 labelColumn,让确认弹层「已选择」摘要显示可读名称。",
|
||||
"placements": ["toolbar", "batch"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "confirm",
|
||||
"confirmation": {
|
||||
"message": "将按技能业务规则处理勾选记录(具体是否跳过某些状态由 CLI 决定)。"
|
||||
},
|
||||
"bind": {
|
||||
"tables": ["business_items"],
|
||||
"inputMapping": {
|
||||
"ids": "$selection.ids"
|
||||
},
|
||||
"labelColumn": "display_name"
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["ids"],
|
||||
"properties": {
|
||||
"ids": {
|
||||
"type": "string",
|
||||
"title": "编号列表",
|
||||
"description": "逗号分隔主键;宿主会预填勾选行",
|
||||
"readOnly": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "process",
|
||||
"args": ["--ids", "{{ids}}"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable"]
|
||||
}
|
||||
}
|
||||
46
assets/examples/selection-mode-optional.action.example.json
Normal file
46
assets/examples/selection-mode-optional.action.example.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"id": "process-queue",
|
||||
"label": "处理队列",
|
||||
"description": "从业务队列处理待处理记录。有勾选则处理勾选的;没有勾选则按下方数量从队列领取。已成功或失败的会自动跳过。进度可在任务中心查看。",
|
||||
"placements": ["toolbar", "batch", "cron", "agent"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"confirmation": {
|
||||
"message": "将按规则处理记录(有勾选则处理勾选的;未勾选则按数量从队列领取),是否继续?"
|
||||
},
|
||||
"bind": {
|
||||
"tables": ["business_items"],
|
||||
"selectionMode": "optional",
|
||||
"labelColumn": "display_name",
|
||||
"inputMapping": {
|
||||
"itemIds": "$selection.ids"
|
||||
}
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["itemIds", "pickCount"],
|
||||
"properties": {
|
||||
"itemIds": {
|
||||
"type": "string",
|
||||
"title": "编号列表",
|
||||
"description": "有勾选时自动填入,一般无需修改",
|
||||
"readOnly": true
|
||||
},
|
||||
"pickCount": {
|
||||
"type": "number",
|
||||
"title": "处理数量",
|
||||
"description": "没有勾选时:从待处理队列里领取的条数",
|
||||
"default": 1,
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "process",
|
||||
"args": ["--ids", "{{itemIds}}", "--pick", "{{pickCount}}"]
|
||||
},
|
||||
"hostEffects": {
|
||||
"onSuccess": ["reloadTable", "clearSelection"]
|
||||
}
|
||||
}
|
||||
29
assets/examples/skill-cell-presentation.example.json
Normal file
29
assets/examples/skill-cell-presentation.example.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
32
assets/examples/skill-grid-presentation.example.json
Normal file
32
assets/examples/skill-grid-presentation.example.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"rules": [
|
||||
{
|
||||
"ruleKey": "qty-low-rows",
|
||||
"rows": ["42", "99"],
|
||||
"columns": "all",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"ruleKey": "status-column",
|
||||
"rows": "all",
|
||||
"columns": ["status"],
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
},
|
||||
{
|
||||
"ruleKey": "overdue-print-region",
|
||||
"rows": ["7", "8"],
|
||||
"columns": ["print_done_at", "ship_done_at"],
|
||||
"tone": "danger",
|
||||
"message": "环节到期未填",
|
||||
"alert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal file
13
assets/examples/skill-policy-snapshot.allowlist.example.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal file
28
assets/examples/skill-policy-snapshot.scope.example.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "1.0.0",
|
||||
"version": "1.0.92",
|
||||
"skill": "your-skill-slug"
|
||||
}
|
||||
|
||||
72
assets/examples/visible-when-action.example.json
Normal file
72
assets/examples/visible-when-action.example.json
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
119
assets/html/example-report.html
Normal file
119
assets/html/example-report.html
Normal file
@@ -0,0 +1,119 @@
|
||||
<!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;
|
||||
--page-pad-x: clamp(14px, 2.5vw, 24px);
|
||||
--page-pad-y: clamp(12px, 2vw, 20px);
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
body {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
line-height: 1.5;
|
||||
}
|
||||
header {
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 8px;
|
||||
background: var(--card);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
header h1 {
|
||||
margin: 0 0 6px;
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
}
|
||||
header p {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
main {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
padding: var(--page-pad-y) var(--page-pad-x) 28px;
|
||||
}
|
||||
.section {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.section h2 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 650;
|
||||
color: var(--muted);
|
||||
}
|
||||
.meta {
|
||||
margin: 0 0 10px;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
padding: 16px 18px;
|
||||
line-height: 1.6;
|
||||
font-size: 14px;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
table-layout: fixed;
|
||||
background: var(--card);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
}
|
||||
th, td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
text-align: left;
|
||||
vertical-align: top;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
}
|
||||
tr:last-child td { border-bottom: none; }
|
||||
/*
|
||||
宿主数据管理在 iframe 内预览本页:应横向铺满可用宽度,仅纵向滚动。
|
||||
复杂看板可在 {{BODY}} 内使用 .section / table;布局底线见 references/ACTIONS.md「宿主预览布局」。
|
||||
*/
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<p>报告生成于 · {{GENERATED_AT}}</p>
|
||||
</header>
|
||||
<main>
|
||||
<div class="card">
|
||||
{{BODY}}
|
||||
</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,3 +11,4 @@ lianlian
|
||||
single-window
|
||||
export-rebate
|
||||
etax
|
||||
colipu
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
all
|
||||
batch
|
||||
multi
|
||||
default
|
||||
|
||||
@@ -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"]
|
||||
@@ -51,10 +110,127 @@
|
||||
"sensitive": { "type": "boolean" },
|
||||
"readOnly": {
|
||||
"type": "boolean",
|
||||
"description": "宿主数据管理表单只读展示该字段(仍会随提交传入 entrypoint);用于行内/勾选预填的主键等"
|
||||
"description": "只读。有「已选择」且字段来自 inputMapping(或约定 id 字段)时,宿主 form 默认不渲染该控件,预设值仍提交;无摘要时只读展示。勿用 visibleWhen 代替(visibleWhen 隐藏不提交)。"
|
||||
},
|
||||
"revealInForm": {
|
||||
"type": "boolean",
|
||||
"description": "可选逃生阀。true 时即使弹层有「已选择」摘要,也仍渲染该 readOnly 字段。不改变提交 payload。省略=映射只读字段在有摘要时默认折叠。"
|
||||
},
|
||||
"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=本地路径选择(可选 pathRoot 指定起始目录,见 ACTIONS.md)。须与 CRUD display_type 语义对齐。"
|
||||
},
|
||||
"format": {
|
||||
"type": "string",
|
||||
"enum": ["textarea", "date", "datetime_unix_seconds", "url", "email", "lookup", "directory", "file"],
|
||||
"description": "可选。display_type 别名,语义相同。"
|
||||
},
|
||||
"pathRoot": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。仅对 display_type/format 为 file 或 directory 有意义:原生选文件/目录对话框的起始文件夹,相对当前技能数据根(如 input/库存数据)。省略则宿主默认打开 {skill_data}/input。禁止绝对路径、盘符与 ..。Agent/CLI 忽略。见 references/ACTIONS.md「本地路径选择与 pathRoot」。"
|
||||
},
|
||||
"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 +296,98 @@
|
||||
"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。"
|
||||
},
|
||||
"labelColumn": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"pattern": "^[a-z][a-z0-9_]*$",
|
||||
"description": "可选。数据管理 Action 确认/表单弹层「已选择」摘要用的可读标签列(#id + 标签)。仅影响展示,不改变提交 payload。省略时宿主按常见列名启发(title/name 等),再不行只显示编号。"
|
||||
},
|
||||
"selectionMode": {
|
||||
"type": "string",
|
||||
"enum": ["required", "none", "optional"],
|
||||
"description": "可选。数据管理勾选如何驱动本 Action:required=必须有勾选(无勾选禁用);none=忽略表勾选(不出现假「已选择」);optional=有勾选走 $selection.ids,无勾选走 pickCount/maxLeads 等空选数量字段。省略时宿主推断:有 $selection.ids 或 batch → required,否则 → none。与 placements/executionProfile 正交。"
|
||||
}
|
||||
},
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping 供行内/批量预填。concurrency/locks 勿写入。"
|
||||
"description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping / labelColumn / selectionMode。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 +407,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 +427,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 +480,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 建议非空(模板测试强制)。"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
78
assets/schemas/skill-cell-presentation.schema.json
Normal file
78
assets/schemas/skill-cell-presentation.schema.json
Normal 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);宿主映射主题样式。整行/整列/区域见 skill-grid-presentation.schema.json,同一 cellPresentation.enabled。用途不限(时效超时、库存预警、状态异常、必填缺失等)。详见 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);填完数据并重算后该项消失则提醒自然关闭"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
93
assets/schemas/skill-grid-presentation.schema.json
Normal file
93
assets/schemas/skill-grid-presentation.schema.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://openclaw.local/skill-template/skill-grid-presentation.schema.json",
|
||||
"title": "SkillGridPresentationDocument",
|
||||
"description": "匠厂数据管理「Grid Presentation」文档(schemaVersion 1)。技能按业务规则为 rows×columns 声明语义 tone(整行、整列、区域或单格);禁止 all×all。非任意 CSS。与 skill-cell-presentation.schema.json(v1 cells[])并存,同一 cellPresentation.enabled。详见 references/CELL_PRESENTATION.md。",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "skill", "table", "rowKey", "rules"],
|
||||
"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);rules[].rows 的 id 取自该列"
|
||||
},
|
||||
"generatedAt": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"description": "可选。ISO-8601 或技能约定的生成时间,便于排障"
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"description": "当前应对该表生效的 grid 规则;空数组 = 该表无 grid 呈现(清除旧行/列/区域样式)。同 ruleKey 由 UNIQUE 约束覆盖。",
|
||||
"items": { "$ref": "#/$defs/ruleItem" }
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"tone": {
|
||||
"type": "string",
|
||||
"enum": ["danger", "warning", "info", "success", "muted"],
|
||||
"description": "语义色板(闭集)。宿主映射到底色/字色/左边线等;禁止技能下发原始 CSS 或任意 hex。"
|
||||
},
|
||||
"axisTarget": {
|
||||
"description": "\"all\" = 该轴全部;非空字符串数组 = 有限集合(行 id 或列名)",
|
||||
"oneOf": [
|
||||
{ "type": "string", "const": "all" },
|
||||
{
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
]
|
||||
},
|
||||
"ruleItem": {
|
||||
"type": "object",
|
||||
"required": ["ruleKey", "rows", "columns", "tone"],
|
||||
"additionalProperties": false,
|
||||
"not": {
|
||||
"properties": {
|
||||
"rows": { "const": "all" },
|
||||
"columns": { "const": "all" }
|
||||
},
|
||||
"required": ["rows", "columns"]
|
||||
},
|
||||
"properties": {
|
||||
"ruleKey": {
|
||||
"type": "string",
|
||||
"minLength": 1,
|
||||
"maxLength": 200,
|
||||
"description": "本表内唯一。建议稳定业务键(重算时覆盖同一条)"
|
||||
},
|
||||
"rows": { "$ref": "#/$defs/axisTarget" },
|
||||
"columns": { "$ref": "#/$defs/axisTarget" },
|
||||
"tone": { "$ref": "#/$defs/tone" },
|
||||
"message": {
|
||||
"type": "string",
|
||||
"maxLength": 500,
|
||||
"description": "可选。悬停/无障碍说明(短文案);不要塞长报告"
|
||||
},
|
||||
"alert": {
|
||||
"type": "boolean",
|
||||
"description": "可选,默认 false。true = 按本条规则计入进页/同步提醒(不按命中格子数)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
196
assets/schemas/skill-policy-snapshot.schema.json
Normal file
196
assets/schemas/skill-policy-snapshot.schema.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -173,7 +173,9 @@ python scripts/main.py account open <account_id>
|
||||
- 租约持有者应使用技能 slug 或可追踪任务标识,`purpose` 写明用途。
|
||||
- 租约 TTL 应覆盖正常任务时长;长任务需要平台支持的续租策略,不能依赖永不过期。
|
||||
- `finally release_lease(...)` 是最低要求,浏览器关闭失败也不能跳过释放尝试。
|
||||
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理。
|
||||
- **清理顺序**:`finally` 内**先 `release_lease`,再关浏览器**。关浏览器(含 CDP / 强杀 Profile 进程)可能耗时或卡住;先释租约可避免「窗还没关完、账号已被下一任务抢走失败」的空窗拉长。
|
||||
- **任务中心中断**:宿主只下发 `stop` 并等待技能退出;技能须在闸门抛出 `JobStopped` 后进入 `finally` 自清。禁止用 `except Exception` 吞掉 `JobStopped`(详见 [`LOGGING.md`](LOGGING.md) §2.5)。不要指望宿主代收租约。
|
||||
- 进程被强制结束后可能留下租约;先用 `lease list --active` 核对,确认没有真实任务仍在使用后再释放或清理;亦可等待 TTL 过期。
|
||||
- 禁止看到租约冲突就直接删数据库记录、删 Profile、换账号或新建重复账号。
|
||||
|
||||
## 9. 工业级测试
|
||||
@@ -184,6 +186,7 @@ python scripts/main.py account open <account_id>
|
||||
- 账号不存在、平台不匹配、Profile 缺失、返回格式异常;
|
||||
- 同一账号并发抢占和 `LEASE_CONFLICT`;
|
||||
- 业务成功、业务失败、用户取消、浏览器崩溃后的租约释放;
|
||||
- **NEED_MANUAL** 任务中心运行中点「中断」:租约变为 `released`(或无 active),马上再采不因残留租约 `LEASE_CONFLICT`;
|
||||
- 登录过期、验证码 / 二次验证、账号风险和权限不足;
|
||||
- 两个以上账号轮换且 Profile、登录态、结果互不污染;
|
||||
- 关闭重开后登录保持;
|
||||
@@ -200,7 +203,7 @@ python scripts/main.py account open <account_id>
|
||||
3. 执行 `account list/get`,核对账号 ID、`profile_dir`、`session_status`。
|
||||
4. 在文件系统确认 `profile_dir` 是否存在、是否错用了其他用户目录。
|
||||
5. 执行 `lease list --active`,确认是否被其他任务占用或残留。
|
||||
6. 查看任务日志、错误码、失败截图和浏览器当前页面。
|
||||
6. 查看运行日志、任务中心终态、错误码、失败截图和浏览器当前页面。
|
||||
7. 判断问题属于账号不存在、租约冲突、登录失效、Profile 损坏、页面变化还是业务失败。
|
||||
|
||||
仍需协助时必须提供:技能 slug、平台、脱敏账号标识 / 账号 ID、任务时间、错误码、账号与租约检查结果、`profile_dir` 是否存在、脱敏日志和截图。禁止发送密码、Cookie、Token 或验证码。
|
||||
|
||||
@@ -102,7 +102,7 @@ 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. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally release lease`;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。
|
||||
5. **所有选择路径都必须取得 lease**:包括纯数字 `DEFAULT_ACCOUNT_ID`。`account get <id>` 只能读取账号,不能单独作为运行结果;必须再通过可定位该账号的 pick 流程取得非空 `lease_token`。随后必须 `finally` **先 `release_lease`、再关浏览器**;进程被 kill 后可能残留 lease,需按 [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) 排查。宿主中断只通知技能,**不**代收租约;禁止 `except Exception` 吞掉 `JobStopped`(见 [`LOGGING.md`](LOGGING.md) §2.5)。
|
||||
6. **网页 RPA 硬规则**(详见 [`RPA.md`](RPA.md) §0.2):必须先经 `pick_web_account`(底层 **`ensure-web` / `pick-web --ensure`**)拿到 `profile_dir` 再开浏览器;拿不到则失败退出;生产有头;**是否站点登录**按平台约定,但**不等于**可以跳过 account-manager。登记账号 ≠ 站点已登录。
|
||||
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` 均须非空;账号平台必须匹配;缺任一字段立即失败,不打开浏览器。
|
||||
@@ -119,10 +119,12 @@ result = call_sibling_json("account-manager", ["list", "--limit", "10"])
|
||||
from service.account_client import pick_web_account, release_lease
|
||||
|
||||
account = pick_web_account(platform="target_platform", login_id=None)
|
||||
lease_token = account.get("lease_token")
|
||||
try:
|
||||
...
|
||||
finally:
|
||||
release_lease(account.get("lease_token"))
|
||||
release_lease(lease_token) # 先释租约
|
||||
# 再关浏览器 / Playwright
|
||||
```
|
||||
|
||||
## 相关文档
|
||||
|
||||
@@ -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 秒**:
|
||||
|
||||
@@ -52,11 +52,15 @@ 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):有处理队列且已声明状态→角色映射 / 不适用
|
||||
- [ ] S0-12 **HTML 页面报告**(见 `references/ACTIONS.md`「HTML 页面报告」):壳子页 `assets/html/pages/*.html` / 快照 `html/*.html` + `render-html` / 不适用
|
||||
|
||||
---
|
||||
|
||||
@@ -73,10 +77,16 @@ checklist_version: 1.0
|
||||
|
||||
- [ ] A2-1 **P0** 市场「说明」能让非开发者理解:做什么、给谁用、开始前准备什么
|
||||
- [ ] A2-2 **P0** 「说明/教程」不以开发目录、内部实现细节作为用户主路径
|
||||
- [ ] A2-3 **P1** 「教程」可按真实顺序跟做(准备 → 步骤 → 失败怎么办)
|
||||
- [ ] A2-3 **P1** 「教程」可按真实顺序跟做(准备 → 分入口步骤 → 失败怎么办);结构见 `TUTORIAL_SURFACES.md`
|
||||
- [ ] A2-4 **P1** 「演示」结构完整(有演示信息位;无视频则标明暂无,不假装有)
|
||||
- [ ] A2-5 **P0** 「更新日志」能对应到当前交付版本
|
||||
- [ ] A2-6 **P0** 平台/Agent 侧说明中,常见用户意图的触发方式与真实能力一致(短操作 vs 长任务不混淆)
|
||||
- [ ] A2-7 **P0** 「说明」Tab(`README.md`)**不含**分步操作(1/2/3)、Agent 话术块、「入口一/二」专章;操作指引指向「使用教程」
|
||||
- [ ] A2-8 **P0** 「教程」Tab 覆盖 `REQUIREMENTS` §0.1 / `actions.json` 中所有「挂」的入口(数据管理 / 对话;结构见 `TUTORIAL_SURFACES.md`)
|
||||
- [ ] A2-9 **P0** 无 `cron` 时教程有「不需要定时」+ 理由;有 `cron` 时教程写清如何配置
|
||||
- [ ] A2-10 **P1** 教程主能力对照表与 `actions.json` 的 `label` 一致
|
||||
- [ ] A2-11 **P1** 若有数据管理主路径:准备/载入/导入类能力在对应表 **toolbar** 可见;教程**不以** skill-detail 为唯一主路径
|
||||
- [ ] A2-12 **P0** 说明/教程正文**不以** `# 技能名` 或 `# 技能名 · 使用教程` 开篇(宿主页头/Tab 已有);主分节用 `##`,不要把原 `##` 升成 `#`(见 `TUTORIAL_SURFACES.md` §0.1)
|
||||
|
||||
---
|
||||
|
||||
@@ -86,6 +96,8 @@ checklist_version: 1.0
|
||||
- [ ] A3-2 **P0** 缺参或非法输入时稳定失败(可读错误,不假成功)
|
||||
- [ ] A3-3 **P1** 失败可诊断:能从任务记录/提示判断失败阶段,而非只有“失败了”
|
||||
- [ ] A3-4 **P0** 同一能力从已声明的不同入口触发时,业务结果一致(不出现入口分叉成两套逻辑)
|
||||
- [ ] A3-5 **P0** `REQUIREMENTS` 入口定位表已填写;定位为「挂」的入口在 `actions.json`(及必要表元数据)中有对应声明
|
||||
- [ ] A3-6 **P1** 定位为「不挂」的入口有书面理由(不是空白遗漏)
|
||||
|
||||
---
|
||||
|
||||
@@ -96,7 +108,11 @@ 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` 混淆)
|
||||
- [ ] A4-8 **P1** 行内/勾选打开的 `form`:有「已选择」时参数区不应再重复映射主键(`inputMapping` + `readOnly`);需要强制露出时用 `revealInForm`,**禁止**用 `visibleWhen` 藏主键(会不提交)
|
||||
- [ ] A4-9 **P1** 若同一能力既支持「勾选处理」又支持「按数量从队列领取」:用一个 Action + `bind.selectionMode: "optional"`(勿拆两个同义顶栏按钮);无勾选可点、有勾选显示「已选择」、空选数量字段用 `pickCount` 等约定名(见 `references/ACTIONS.md`)
|
||||
- [ ] A4-10 **P0** 数据管理弹窗与任务失败文案:全中文产品用语;禁止在 `description` / `confirmation.message` / 参数 `description` / 用户向 `message` 写英文字段名、CLI 子命令、`async`/`pending`/`status=` 等(Agent 路由放 `SKILL.md`;见 `references/ACTIONS.md`「用户可见文案」)
|
||||
---
|
||||
|
||||
## 5. 账号与浏览器会话(网页 RPA 适用;其他形态标 N/A 或改测等价项)
|
||||
@@ -109,10 +125,11 @@ checklist_version: 1.0
|
||||
- [ ] A5-6 **P0** **NEED_MANUAL** 真实站或约定仿真站主路径至少完整跑通 1 次;仅模拟数据通过不算交活
|
||||
- [ ] A5-7 **P0** 账号创建后可在宿主数据管理和 `account list/get` 查到,返回的 `profile_dir` 在当前用户数据目录真实存在
|
||||
- [ ] A5-8 **P0** 每个账号使用独立且稳定的 Profile;不同账号不共用,本机日常浏览器目录不参与运行
|
||||
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放
|
||||
- [ ] A5-9 **P0** 指定账号 ID、指定登录标识和自动选择均取得非空租约;成功、失败、取消和异常路径最终释放;`finally` 先释租约再关浏览器
|
||||
- [ ] A5-10 **P0** **NEED_MANUAL** 登录后关闭浏览器并释放租约,再取同一账号重开,登录状态仍有效;失效时明确提示重新登录
|
||||
- [ ] A5-11 **P1** 至少两个账号连续或轮换测试通过,Profile、登录态、结果和失败互不污染
|
||||
- [ ] A5-12 **P1** 租约冲突、残留租约和账号登录失效能按文档自助定位,不通过删库、删 Profile 或自动换号处理
|
||||
- [ ] A5-13 **P0** **NEED_MANUAL** 任务中心运行中点「中断」:技能响应 `JobStopped` 并释放租约(数据管理「账号租约」为 `released` / 无 active);立即再跑不因残留租约失败;进度 `emit` 不得用裸 `except Exception` 吞掉停止
|
||||
|
||||
---
|
||||
|
||||
@@ -123,6 +140,16 @@ 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 确认文案写清仅可操作态
|
||||
- [ ] A6-10 **P1** 有 HTML 侧栏预览时(S0-12):预览区横向铺满、无横向滚动条;重新生成后内容与页头时间更新;布局符合 `references/ACTIONS.md`「宿主预览布局」。无 HTML 报告 → **N/A**
|
||||
- [ ] A6-11 **P1** 业务**状态 / 启用类**列若用户需要筛表或顶栏搜中文:`searchable: 1` 且 `options_json`(或 `boolean` + `options_json`)的 **value 与存库一致**;仅 `searchable: 0` 则宿主不提供列筛与全局搜索。见 `references/SCHEMA.md`「列筛选与全局搜索」
|
||||
- [ ] A6-12 **P2** `display_type: boolean` 的展示列推荐同时配置 `options_json` 中文 label(如 停用/启用),避免仅靠宿主默认「是/否」;无 boolean 列 → **N/A**
|
||||
- [ ] A6-13 **P0** 新技能**不得**创建 `task_logs`;过程/进度/业务结果走运行日志、任务中心、业务表(见 `LOGGING.md`、`SCHEMA.md`)
|
||||
- [ ] A6-14 **P0** 废弃用户可见表时:`drop_deprecated_user_table` 物理删表 + 清元数据;**禁止**只删元数据留物理表;有迁移时须提供旧库回归测试(见 `SCHEMA.md`、`tests/samples/test_table_deprecation_migration.py.sample`)
|
||||
- [ ] A6-15 **P1** 若启用 `cellPresentation`:旁路表不得出现在数据管理侧栏;整行着色不得铺到详情每个字段;禁止 `all`×`all`;tone 闭集。未启用 → **N/A**(见 `references/CELL_PRESENTATION.md`)
|
||||
|
||||
---
|
||||
|
||||
@@ -133,7 +160,8 @@ checklist_version: 1.0
|
||||
- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态
|
||||
- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`)
|
||||
- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL**
|
||||
- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话
|
||||
- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话(数据管理弹窗 / 任务失败见 **A4-10**,更高优先级)
|
||||
- [ ] A7-7 **P2** 数据管理技能按钮(toolbar / row / batch)中文名对照 `references/ACTIONS.md`「Action 按钮中文名」;不与宿主「查看 / 编辑 / 删除」抢名;打开类成功不弹成功条。行业专有按钮名可自创
|
||||
|
||||
---
|
||||
|
||||
@@ -162,8 +190,8 @@ checklist_version: 1.0
|
||||
|
||||
- [ ] A9-1 **P0** 仓库默认测试套件通过(执行技能约定的一键测试命令,如 `python tests/run_tests.py -v`)
|
||||
- [ ] A9-2 **P0** 不以「仅模拟档通过」作为交活依据(有真实档要求时必须真实跑通或书面阶段性豁免)
|
||||
- [ ] A9-3 **P0** **NEED_MANUAL** 已声明的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过
|
||||
- [ ] A9-4 **P1** 对外版本一致:详情、version、更新日志、发布标签能对上
|
||||
- [ ] A9-3 **P0** **NEED_MANUAL** 入口定位表中定为「挂」的宿主入口(详情/Agent/工具栏/定时等)按声明抽测可通过;未在定位表中规划且未声明的入口可标 N/A(须引用定位表理由),**禁止**用「未声明」掩盖「从未做入口定位」
|
||||
- [ ] A9-4 **P0** 对外版本一致(POLICY-RELEASE-001):`SKILL.md` version = `SKILL_VERSION` = 发布 git tag(无前缀);`release.ps1` 打 tag 前会硬校验,漏改不得发布;CHANGELOG 有对应小节
|
||||
- [ ] A9-5 **P1** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
|
||||
|
||||
---
|
||||
|
||||
@@ -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,7 +170,8 @@ 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`:仅**纯数字**视为账号主键;校验指定记录后仍必须取得该账号租约,不能只 `account get`
|
||||
- `DEFAULT_LOGIN_ID`:手机号 / 用户名等;留空则按平台 `ensure-web`;非数字 `DEFAULT_ACCOUNT_ID` 可兼容为 login_id,但不推荐
|
||||
|
||||
@@ -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,21 @@ 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/`
|
||||
- **Action 表单**里 `display_type`/`format` 为 `file` 或 `directory` 时:宿主原生对话框默认打开**当前技能** `{skill_data}/input`;若字段声明可选 **`pathRoot`**(相对技能数据根,如 `input/库存数据`),则打开该子目录。禁止在技能里写用户 ID 或绝对盘符。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「本地路径选择与 pathRoot」
|
||||
- 若表声明了 `replaces: import`,确认导入后由技能 CLI 写库(见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「`replaces`」);仅有 `replaces: create` 时宿主可按行调 create(兼容)
|
||||
- 数据管理扫描两处 HTML(都只扫一层):安装包 `assets/html/pages/*.html` 是装完就有的壳子页;`{skill_data}/html/*.html` 是快照,「重新生成」调用 Action id **`render-html`**。页面里用 `jiangchang.call` 调本技能已声明的命令。详见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」
|
||||
|
||||
---
|
||||
|
||||
## 3. 两类路径
|
||||
@@ -54,6 +81,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 +89,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 +100,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 +110,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 +122,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 +150,8 @@ os.path.abspath(config.get("DOWNLOAD_OUTPUT_DIR"))
|
||||
|
||||
# ❌ .env.example 里写 ./outputs/videos
|
||||
DOWNLOAD_OUTPUT_DIR=./outputs/videos
|
||||
|
||||
# ❌ 再拆 downloads / imports / exports 三个用户目录
|
||||
```
|
||||
|
||||
---
|
||||
@@ -138,7 +167,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/` **分工不同**:前者是业务产出文件,后者是操作过程录屏。
|
||||
|
||||
---
|
||||
|
||||
@@ -148,6 +177,10 @@ DOWNLOAD_OUTPUT_DIR=./outputs/videos
|
||||
- `tests/test_runtime_paths.py` 覆盖 `resolve_data_path` / `resolve_input_path`。
|
||||
- `POLICY-DATA-PATH-001`:禁止 `.env.example` 中 `./` 路径;禁止业务代码 `abspath(config.get(...))`。
|
||||
|
||||
### 卸载技能与数据保留
|
||||
|
||||
宿主**卸载技能只删除安装目录**(如 `.openclaw/skills/{slug}`),**不会**删除 `{JIANGCHANG_DATA_ROOT}/{user_id}/{skill_slug}/` 下的 SQLite、输入输出与日志。重装会复用同一用户库;schema 迁移须在 `init_db()` 中幂等完成(见 [`../references/SCHEMA.md`](../references/SCHEMA.md)「表增删改与退役」)。
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文档
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
本文默认你开发的是当前最常见的一类业务 skill:
|
||||
|
||||
- 有明确的 `scripts/main.py` CLI 入口
|
||||
- 可能需要读写本地 SQLite(例如任务日志 `task_logs`)
|
||||
- 可能需要读写本地 SQLite(业务表按需创建,不是默认必有)
|
||||
- 可能需要调用兄弟技能或外部 HTTP / RPA
|
||||
- 业务逻辑主要放在 `scripts/service/`
|
||||
|
||||
@@ -127,7 +127,7 @@ 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
|
||||
|
||||
@@ -158,7 +158,7 @@ scripts/
|
||||
```
|
||||
上述 import 来自宿主共享 runtime 安装的 `jiangchang-platform-kit`,不是技能目录副本。
|
||||
4. **单测/CI 须 mock 离线可过**;业务 `.env` 默认 `real_rpa`,须真实跑通。**不得只 mock 通就交活**;须 release 到匠厂宿主验收(见 §15,含用户体验)。多账号或矩阵能力还须按 `ACCOUNT_MANAGER.md` 覆盖至少两个账号、租约冲突、关闭重开保持登录和异常释放,不能以单账号一两次成功代替规模验证。
|
||||
5. **Windows 桌面**:技术路线已由 `add-friend-wecom` 验证。先读 `examples/real_desktop_rpa/README.md`,复用窗口唯一性、UIA作用域、probe、dry-run/assisted 和失败留证范式;目标软件 selector 必须重新拾取,禁止照抄企业微信控件名。
|
||||
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 / 注入、验证码破解、设备指纹伪造、自动换号或无限重试。
|
||||
@@ -170,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 时的冲突风险。推荐范围写法:
|
||||
@@ -335,7 +335,7 @@ git remote -v
|
||||
|
||||
应该重点写:
|
||||
|
||||
- YAML frontmatter:`name`、`description`、`version`、`metadata.openclaw`(含 `slug`、`platform_kit_min_version`、`developer_ids` 等)
|
||||
- YAML frontmatter:`name`、`description`、`version`、`metadata.openclaw`(含 `slug`、`platform_kit_min_version`、`developer_ids`、可选 `brands` 等)
|
||||
- 何时触发本技能、CLI 入口摘要、运行契约要点
|
||||
- 指向 `references/`(Agent 调用)与 `development/`(开发规范)的分工说明
|
||||
|
||||
@@ -352,9 +352,9 @@ git remote -v
|
||||
| `DEMO.md` | 演示 | `demo_md` + frontmatter `demo_video_url` |
|
||||
| `CHANGELOG.md` | 更新日志 | 匹配当前 tag 版本的 `##` 小节 → `changelog` |
|
||||
|
||||
**`README.md`**:能力与场景说明。含 frontmatter `description`;写能做什么、场景、准备、如何告诉 Agent、结果、注意事项、FAQ。不要写开发教程、目录规范、release、requirements。
|
||||
**`README.md`(说明 Tab)**:能力、场景、前置条件、结果形态、注意事项、概念类 FAQ。含 frontmatter `description`(**不要**写「如何开始使用」)。**禁止**分步操作、Agent 话术块、「入口一/二」专章、逐步排障——这些属教程。**禁止**文首再写 `# 技能名`(页头已有);正文主分节用 `##`。完整契约见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) §0.1 / §1。
|
||||
|
||||
**`TUTORIAL.md`**:第一次怎么用完的逐步操作。写「开始前 → 步骤 → 卡住怎么办」;不要写 CLI / 目录 / 开发规范。
|
||||
**`TUTORIAL.md`(教程 Tab)**:按 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) 写三入口结构(数据管理 ∥ 对话;定时有则教、无则 N/A)+ 主能力对照表 + 常见卡住。步骤必须与 `REQUIREMENTS` §0.1、`actions.json` 及宿主真实 UI 对齐;**禁止**把仅 `skill-detail` 的 Action 写成用户主路径。**禁止**文首 `# 技能名 · 使用教程`;正文最高用 `##`。不要写 CLI / 目录 / 开发规范。
|
||||
|
||||
**`DEMO.md`**:演示视频与补充说明。frontmatter 必须含 `demo_video_url`(无视频时用 `""`);正文可选。
|
||||
|
||||
@@ -407,6 +407,51 @@ metadata:
|
||||
- 一期只做“补授权”,不会因为你 later 修改数组而自动撤销旧授权
|
||||
- **首次正式 release 前**就应配好;配错或漏配时,CI 可能成功,但你在技能市场仍找不到技能
|
||||
|
||||
### 关于 `metadata.openclaw.brands`(OEM 市场,按需)
|
||||
|
||||
#### 目的
|
||||
|
||||
匠厂与华商、Lili、GDCM 等 OEM 客户端共用同一技能库,但技能市场目录按 **`market_code`** 过滤(客户端 Header `X-Market-Code`;缺省为 `jiangchang`)。
|
||||
|
||||
发布接口 `POST /api/skill/update` 会把 `metadata.openclaw.brands` 幂等写入 `skill_market_relations`(**只补不删**)。**未声明 `brands` 时仅补默认 `jiangchang`**,因此只写在 Gitea 的 `client-huashang` 组织下、却不写 `brands: [huashang]`,华商市场仍然搜不到该技能。
|
||||
|
||||
`brands` 与 `developer_ids` 正交:
|
||||
|
||||
| 字段 | 管什么 |
|
||||
|------|--------|
|
||||
| `brands` | 技能出现在哪些 OEM 市场目录 |
|
||||
| `developer_ids` | 技能不公开(`access_scope = 0`)时,哪些用户仍可见并可安装 |
|
||||
|
||||
#### 何时填写
|
||||
|
||||
- **仅匠厂验收 / 通用技能**:可省略(默认匠厂)。
|
||||
- **须在华商或其他 OEM 客户端出现**:必须显式列出对应编码(通常同时保留 `jiangchang` 以便主站也能看到)。
|
||||
|
||||
常见编码(小写机器码,非中文展示名):`jiangchang`、`huashang`、`lili`、`gdcm` 等。非法编码会导致发布接口 400,CI 失败。
|
||||
|
||||
#### 在 `SKILL.md` 中声明
|
||||
|
||||
```yaml
|
||||
metadata:
|
||||
openclaw:
|
||||
slug: your-skill-slug
|
||||
category: 通用
|
||||
developer_ids:
|
||||
- 12345
|
||||
brands:
|
||||
- jiangchang
|
||||
- huashang
|
||||
```
|
||||
|
||||
兼容别名(平台按优先级取第一个出现的键):`brands` → `oem_clients` → `client_keys`。模板与文档统一推荐写 **`brands`**。
|
||||
|
||||
约定:
|
||||
|
||||
- **可选字段**:未写或空列表 → 行为与历史一致,只挂 `jiangchang`
|
||||
- **一期只补不删**:从 `brands` 去掉某市场后,不会自动删除库中已有关联;若需下架请走运营/后台或后续平台能力
|
||||
- **不要**指望 Gitea 组织名、目录名 `client-*` 自动映射市场
|
||||
- 发布后在对应 OEM 客户端(须带正确 `X-Market-Code`)搜索验收;也可查库 `skill_market_relations`
|
||||
|
||||
## 7. 文档目录分工
|
||||
|
||||
当前规范 skill 的文档按读者拆分:
|
||||
@@ -448,10 +493,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/`。
|
||||
@@ -466,7 +511,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` 里直接写:
|
||||
@@ -505,7 +550,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 做排障日志。
|
||||
@@ -526,10 +571,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`
|
||||
@@ -543,17 +589,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
|
||||
|
||||
宿主执行方式:
|
||||
@@ -586,6 +635,7 @@ cmd_stats() # 只读 sync:JSON,无 job_context
|
||||
- 批量数量用 **`pickCount` / `--pick N` 参数**(任意正整数),不写死条数。
|
||||
- **顺序执行、不并发**同一浏览器 Profile / account lease。
|
||||
- **同步数据**与**宿主表导出**职责分离:同步不写 CSV/Excel;表导出交给数据管理。
|
||||
- **HTML 页面报告**(可选):壳子页放 `assets/html/pages/*.html`,装完即进侧栏;快照才写入 `html/` + Action `render-html`。页面用 `jiangchang.call` 调已声明命令。契约见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「HTML 页面报告」(含**宿主预览布局**底线)、[`DATA_PATHS.md`](DATA_PATHS.md)。快照可从 `assets/html/example-report.html` 复制壳,不要把该文件放进 `pages/`。
|
||||
- 详情见 [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md);契约样例见 `tests/samples/test_sync_contract.py.sample`、`tests/samples/test_action_core_contract.py.sample`。
|
||||
|
||||
### 队列 + pick(可选)
|
||||
@@ -666,18 +716,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
|
||||
@@ -723,9 +765,15 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
|
||||
在执行 `release.ps1` 之前,必须先在本地确认 `python tests/run_tests.py -v` 通过。测试不通过不得发起正式发布。
|
||||
|
||||
**发布前必须**将 `SKILL.md` 的 `version:` 与 `scripts/util/constants.py` 的 `SKILL_VERSION` 调整为将打出的版本(例如下一 patch),再执行 `release.ps1`。`release.ps1` **不会**自动改这两个字段;漏改会造成 git tag 与技能版本号不一致。
|
||||
**POLICY-RELEASE-001(硬门禁)**:发布前必须将 `SKILL.md` 的 `version:`、`scripts/util/constants.py` 的 `SKILL_VERSION`、以及 `CHANGELOG.md` 对应小节,调整为**即将打出的**版本(与 `.\release.ps1 -DryRun` 打印的 `Next tag` 去掉 `v` 前缀一致)。`release.ps1` **不会**自动改这些字段;若三者与 Next tag 不一致,脚本在跑测试/打 tag **之前**直接失败,避免市场「最新版本」(git tag) 与安装包「当前版本」(本地 `SKILL.md`) 长期错位、技能市场一直显示「更新」。
|
||||
|
||||
`release.ps1` 会在 **auto commit 之前**、推送 tag 之前,对**当前工作区**自动执行默认测试(`python tests/run_tests.py -v`);失败则中止 release,不会留下失败状态的自动提交。仅紧急排障可使用 `-SkipTests` 跳过(会打印黄色警告)。`-DryRun` 预览发布时**仍会实际运行**默认测试,便于在不打 tag 的情况下验证门禁。
|
||||
建议流程:
|
||||
|
||||
1. `.\release.ps1 -DryRun` 查看 `Next tag`(例如 `v1.0.104`)
|
||||
2. 将 `SKILL.md` / `SKILL_VERSION` / `CHANGELOG` 改为 `1.0.104`
|
||||
3. 再执行正式 `.\release.ps1`(可用 `-AutoCommit`)
|
||||
|
||||
`release.ps1` 会在 **auto commit 之前**、推送 tag 之前,对**当前工作区**自动执行默认测试(`python tests/run_tests.py -v`);失败则中止 release,不会留下失败状态的自动提交。仅紧急排障可使用 `-SkipTests` 跳过(会打印黄色警告)。`-DryRun` 预览发布时**仍会实际运行**版本对齐门禁与默认测试,便于在不打 tag 的情况下验证。
|
||||
|
||||
新增或调整开发规范时,须同步 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md) 与 [`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py)(或注明由既有测试覆盖)。
|
||||
|
||||
@@ -825,9 +873,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))。
|
||||
|
||||
- 定位为「挂」却未声明或测不到 → **验收失败**
|
||||
- 定位为「不挂」且写了理由 → 可跳过该入口
|
||||
- **禁止**从未做入口定位、却用「未声明所以跳过」交差
|
||||
- 不要只测对话、忽略定位表中已挂的其它入口
|
||||
|
||||
**用户体验(与功能入口并列,后台通了不算完):**
|
||||
|
||||
@@ -835,13 +888,14 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
- 长任务(async)是否有可见进度,能否取消或等待说明
|
||||
- 网页 RPA 是否有头运行;需登录/验证码时是否停下等人,而不是静默失败
|
||||
- 数据管理:表名/字段中文、按钮文案是否清楚;结果是否写回用户看得见的地方
|
||||
- 市场说明 / 教程是否与真实行为一致
|
||||
- 市场说明 / 教程是否与真实行为一致;**说明 Tab 不含分步操作**,教程覆盖已挂入口(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md))
|
||||
|
||||
| 宿主入口(侧栏 / 界面) | 技能侧如何挂上 | 建议验收什么 |
|
||||
|-------------------------|----------------|--------------|
|
||||
| **新建任务**(对话 Agent) | `placements` 含 `agent`;或短查询走共享 Python CLI | 自然语言能触发主流程;长任务 / RPA 须走 `run_skill_action`,禁止 bash 干等 |
|
||||
| **数据管理 · 表顶栏** | `placements` 含 `toolbar`(须合法非空 `bind.tables`) | 左侧能看到本技能库表;表顶栏出现按钮;点按行为符合预期 |
|
||||
| **数据管理 · 行内** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 行内操作链接可见(需有主键列);点按后入参/行为正确 |
|
||||
| **数据管理 · HTML 报告** | 壳子页放 `assets/html/pages/*.html`;快照写出 `html/*.html` 并提供 `render-html` | 壳子装完即现、右键无「重新生成」;快照可重新生成;页面 `jiangchang.call` 能拿到 JSON |
|
||||
| **数据管理 · 行级** | `placements` 含 `row`(建议 `bind.tables` + `bind.inputMapping` 预填主键) | 操作列和/或行右键(及可选「⋯」)出现本技能行级 Action(需有主键列);点按后入参/行为与壳无关、均正确 |
|
||||
| **数据管理 · 批量** | `placements` 含 `batch` | 勾选行后批量按钮可用;无勾选时禁用;批量行为正确 |
|
||||
| **定时任务** | `placements` 含 `cron`(创建时选「技能直调」) | 能选到本技能 Action、保存并触发;到点或「立即运行」行为正确 |
|
||||
| **任务中心** | 不是 placement;由 `executionProfile: "async"` 决定是否进 Job | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
|
||||
@@ -871,10 +925,11 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
|
||||
每个新 skill 发布前,建议技术人员逐条确认:
|
||||
|
||||
- [ ] 目录结构符合当前模板
|
||||
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform)
|
||||
- [ ] slug 符合 [`NAMING.md`](NAMING.md)(动词-名词,平台可选;不要用 `default` 占位)
|
||||
- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致
|
||||
- [ ] `SKILL.md` 中 slug、名称、描述都已替换
|
||||
- [ ] `SKILL.md` 的 `developer_ids` 已换成**本人**匠厂用户 ID(设置 → 用户信息 → 复制),不是模板示例 ID
|
||||
- [ ] 若须在华商等 OEM 市场出现:`SKILL.md` 的 `brands` 已声明(如 `jiangchang` + `huashang`);未写则仅匠厂;不要用 Gitea 组织名代替
|
||||
- [ ] `scripts/util/constants.py` 已修改
|
||||
- [ ] `../references/CLI.md` 示例命令已改成真实命令
|
||||
- [ ] `service` 下的核心业务文件(如 `task_service.py`)已按领域改名并实现
|
||||
@@ -885,7 +940,7 @@ 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`)
|
||||
- [ ] 主任务入口有关键日志事件词:`task_start`、`task_log_saved`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3)
|
||||
- [ ] 主任务入口有关键日志事件词:`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))
|
||||
|
||||
@@ -11,9 +11,9 @@
|
||||
| 通道 | 给谁看 | 能否单独当排障依据 |
|
||||
|------|--------|-------------------|
|
||||
| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** |
|
||||
| Activity / 任务中心 `emit` | 用户进度 | **不能**替代文件日志 |
|
||||
| Activity / 任务中心 `emit` / `finish` | 用户进度与终态 | **不能**替代文件日志 |
|
||||
| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 |
|
||||
| `task_logs` | 终态审计 | 摘要层;**不能**替代逐步 trace |
|
||||
| 业务表 | 用户可见业务结果 | 有业务才建;**不是**默认排障通道 |
|
||||
|
||||
硬规定:
|
||||
|
||||
@@ -48,7 +48,7 @@ OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览
|
||||
|
||||
## 2. 日志分层
|
||||
|
||||
技能日志分四层,职责互不替代:
|
||||
技能日志分三层,职责互不替代:
|
||||
|
||||
### 2.1 统一文件日志(开发 / 运维排查)
|
||||
|
||||
@@ -122,17 +122,35 @@ RPA 等待用 `interruptible_sleep`,**不要**裸 `asyncio.sleep`(否则暂
|
||||
|
||||
Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`),供宿主任务中心显示状态。
|
||||
|
||||
**宿主中断(`stop`)与 `JobStopped`(必读):**
|
||||
|
||||
### 2.3 `task_logs`(任务结果审计)
|
||||
- 用户在任务中心点「中断」时,宿主写入 `control.json` `command=stop`,并**等待技能进程协作退出**(超时后才可能强杀)。
|
||||
- 技能在 `emit` / `checkpoint` / `interruptible_sleep` / `rpa_step` 等闸门处会抛出 `JobStopped`;业务代码须让其冒泡到 `job_context` / 外层 `finally`,由技能**自己**释放租约、关闭浏览器等资源。
|
||||
- **禁止**用裸 `except Exception:` 包住上述闸门调用。`JobStopped` 是 `Exception` 子类,会被吞掉,导致 UI 已停、进程仍占账号租约,下次采集必 `LEASE_CONFLICT`。
|
||||
- 若必须兜底「emit 写 Journal 失败」,正确写法:
|
||||
|
||||
**来源**:`db/task_logs_repository.save_task_log`
|
||||
```python
|
||||
from jiangchang_skill_core.activity import JobStopped, emit
|
||||
|
||||
- 适合 `python scripts/main.py logs` / `log-get <id>` 查询。
|
||||
- 记录任务最终状态、`result_summary`(可含 `video_path`、`video_log` 等)。
|
||||
- 面向「这次任务成功还是失败、摘要是什么」,不是逐步 trace。
|
||||
- **鉴权失败与未捕获异常也必须写入 `task_logs`**,保证 `logs` / `log-get` 排查链不断(见模板 `cmd_run` 示范)。
|
||||
try:
|
||||
emit("正在处理…", skill=SKILL_SLUG, stage="fetch")
|
||||
except JobStopped:
|
||||
raise
|
||||
except Exception:
|
||||
log.debug("emit_failed", exc_info=True)
|
||||
```
|
||||
|
||||
表结构与字段见 [`references/SCHEMA.md`](../references/SCHEMA.md)。
|
||||
- 宿主**不会**按技能类型统一代收 account-manager 租约(许多技能根本不用账号管理)。强杀后的残留靠租约 TTL / 数据管理手工 force-release;主路径必须是技能自清。
|
||||
|
||||
### 2.3 可观测性三分法(禁止 task_logs)
|
||||
|
||||
| 信息类型 | 去哪里 | 技能怎么做 |
|
||||
|----------|--------|------------|
|
||||
| **过程**(步骤、异常栈、排查) | `jiangchang.log` / 宿主「运行日志」 | `log.info` / `log.exception`,关键节点见 §3 |
|
||||
| **进度 / 终态**(任务中心) | Run Journal:`emit` / `finish` | `job_context` + `finish(status=...)` |
|
||||
| **业务结果** | 业务表字段(status、产物路径、统计等) | repository 回写;见 [`WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md) |
|
||||
|
||||
模板**默认不建** `task_logs` 等通用任务审计表。**不要**用业务表冒充任务进度,也不要用任务表代替业务状态机。
|
||||
|
||||
### 2.4 RPA video step / artifacts(用户可见动作与存证)
|
||||
|
||||
@@ -140,7 +158,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` 控制,默认开)
|
||||
|
||||
@@ -161,7 +179,6 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping`
|
||||
| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 |
|
||||
| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 |
|
||||
| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` |
|
||||
| `task_log_saved` | info | 写入 `task_logs` 后 |
|
||||
|
||||
### 3.2 RPA / 桌面 / 浏览器额外最低集
|
||||
|
||||
@@ -198,7 +215,6 @@ rpa_start stage=open_window
|
||||
rpa_start stage=locate_control
|
||||
rpa_start stage=submit
|
||||
task_failed error_code=ELEMENT_NOT_FOUND ...
|
||||
task_log_saved status=failed
|
||||
```
|
||||
|
||||
成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。
|
||||
@@ -224,7 +240,7 @@ task_log_saved status=failed
|
||||
|
||||
| 字段 | 含义 |
|
||||
|------|------|
|
||||
| `task_type` | 任务类型(与 `task_logs.task_type` 对齐) |
|
||||
| `task_type` | 任务类型 |
|
||||
| `target_id` | 目标标识(平台、店铺、报表类型等) |
|
||||
| `input_id` | 输入记录 ID |
|
||||
| `batch_id` | 批次 / 录屏批次 |
|
||||
@@ -251,7 +267,7 @@ batch_progress current=7 total=20 target_id=store-001
|
||||
|
||||
| 级别 | 使用场景 |
|
||||
|------|----------|
|
||||
| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度、`task_log_saved` |
|
||||
| **info** | 关键阶段、成功路径、外部调用开始/结束、批量进度 |
|
||||
| **warning** | 可恢复问题、重试、跳过、人工介入、部分失败、鉴权失败(业务可预期) |
|
||||
| **exception** | 异常路径;**必须**用 `log.exception(...)` 保留 traceback |
|
||||
| **debug** | 低频诊断;**默认不依赖 debug 才能排查问题** |
|
||||
@@ -264,7 +280,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`
|
||||
- 验证码、动态口令全文
|
||||
@@ -318,8 +334,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:
|
||||
@@ -328,7 +342,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
|
||||
```
|
||||
@@ -342,11 +355,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,再看统一日志。
|
||||
|
||||
@@ -359,8 +371,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` 边界 |
|
||||
|
||||
@@ -13,23 +13,24 @@
|
||||
| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 |
|
||||
| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 |
|
||||
|
||||
**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。
|
||||
**新技能**使用「动词-名词」,外部平台可选。不要再为了凑段数追加 `default`。校验器不追溯已上线仓库;已经发布、末尾是 `default` 的技能不用改名。
|
||||
|
||||
---
|
||||
|
||||
## 2. 命名格式
|
||||
|
||||
标准形态(约 95% 新技能):
|
||||
标准形态:
|
||||
|
||||
```text
|
||||
{verb}-{noun-phrase}-{platform}
|
||||
{verb}-{noun-phrase}
|
||||
{verb}-{noun-phrase}-{platform} # 仅当技能绑定外部平台时
|
||||
```
|
||||
|
||||
| 部分 | 规则 |
|
||||
|------|------|
|
||||
| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) |
|
||||
| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 |
|
||||
| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
|
||||
| **platform** | **可选**。没有抖音、小红书、亚马逊这类外部系统时省略。有平台时 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) |
|
||||
|
||||
**展示层分工不变:**
|
||||
|
||||
@@ -44,8 +45,8 @@
|
||||
|
||||
| 约束 | 值 |
|
||||
|------|-----|
|
||||
| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) |
|
||||
| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) |
|
||||
| 格式 | `{verb}-{noun-phrase}`;有外部平台时才加 `-{platform}` |
|
||||
| 总段数(按 `-` 分割) | **2~5 段** |
|
||||
| 总长度 | **≤ 48 字符** |
|
||||
| 字符集 | 小写字母、数字、连字符;kebab-case |
|
||||
| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) |
|
||||
@@ -56,12 +57,26 @@
|
||||
|
||||
## 4. 三类合法形态
|
||||
|
||||
### 4.1 标准型(95%)
|
||||
### 4.1 无平台(常见)
|
||||
|
||||
`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段)
|
||||
`verb-noun` 或 `verb-noun-noun`(2~4 段)
|
||||
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单
|
||||
- 其余为 noun-phrase(1~3 段)
|
||||
- **不要**追加 `default`
|
||||
|
||||
示例:
|
||||
|
||||
- `generate-monthly-statements`
|
||||
- `reconcile-finance`
|
||||
- `process-article`
|
||||
|
||||
### 4.2 标准型(绑定外部平台)
|
||||
|
||||
`verb-noun-platform`(在无平台形态后加平台,总段数仍 ≤ 5)
|
||||
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 最后 1 段(或 hyphenated 平台的最后若干段)∈ 平台白名单
|
||||
- 中间为 noun-phrase(1~3 段)
|
||||
|
||||
示例:
|
||||
@@ -71,19 +86,19 @@
|
||||
- `enter-sales-receipt-kingdee`
|
||||
- `fill-export-declaration-single-window`
|
||||
|
||||
### 4.2 scope 型(少见)
|
||||
### 4.3 scope 型(少见)
|
||||
|
||||
`verb-noun-scope`
|
||||
|
||||
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default`
|
||||
- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi`
|
||||
- 第 1 段 ∈ 动词白名单
|
||||
- 总段数 3~5
|
||||
- 至少还有一段名词(因此最少 3 段)
|
||||
|
||||
示例:`generate-article-all`
|
||||
|
||||
> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。
|
||||
> `default` 已从 scope 词表移除。它不是平台,也不表示范围。新技能不要写 `generate-article-default` 这类占位。已发布且末尾为 `default` 的仓库保持原 slug;同步本校验后只会警告,不会因此失败。
|
||||
|
||||
### 4.3 历史豁免(不校验语义)
|
||||
### 4.4 历史豁免(不校验语义)
|
||||
|
||||
以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`):
|
||||
|
||||
@@ -121,7 +136,7 @@
|
||||
| lianlian | single-window | export-rebate |
|
||||
| etax | | |
|
||||
|
||||
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default`
|
||||
scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`。不含 `default`。
|
||||
|
||||
---
|
||||
|
||||
@@ -164,7 +179,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
## 9. 复制新技能流程
|
||||
|
||||
1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符
|
||||
1. **先定 slug** — `{verb}-{noun-phrase}`,仅在绑定外部平台时加 `-{platform}`。确认动词/平台在白名单内且 ≤ 48 字符。不要用 `default` 占位。
|
||||
2. **复制目录** — **推荐** [`tools/scaffold_skill.ps1`](../tools/scaffold_skill.ps1)(`-Slug` 与目录名一致);勿整包复制带走 `.git`
|
||||
2.5. **清理 Git(若未用 scaffold)** — 删除 `.git`、`.openclaw-skill-template`、`.pytest_cache`;再 `git init` 与 `git remote add origin`
|
||||
3. **全局替换** — `your-skill-slug` → 新 slug;更新 `SKILL.md`、`constants.py`、文档中的占位
|
||||
@@ -179,10 +194,12 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
| slug | 说明 |
|
||||
|------|------|
|
||||
| `download-settlement-report-amazon` | 标准型,4 段 |
|
||||
| `add-product-shopee` | 标准型,3 段 |
|
||||
| `enter-sales-receipt-kingdee` | 标准型 |
|
||||
| `fill-export-declaration-single-window` | 标准型,平台含连字符 |
|
||||
| `generate-monthly-statements` | 无平台 |
|
||||
| `reconcile-finance` | 无平台,动词 + 一个名词 |
|
||||
| `download-settlement-report-amazon` | 有平台 |
|
||||
| `add-product-shopee` | 有平台 |
|
||||
| `enter-sales-receipt-kingdee` | 有平台 |
|
||||
| `fill-export-declaration-single-window` | 有平台,平台含连字符 |
|
||||
| `generate-article-all` | scope 型 |
|
||||
|
||||
### ❌ 反例
|
||||
@@ -191,7 +208,7 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|------|------|
|
||||
| `amazon-download-settlement-report` | 平台在最前 |
|
||||
| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 |
|
||||
| `reconcile-finance` | 新技能禁止 2 段无平台 |
|
||||
| `generate-article-default` | `default` 不是平台;应写成 `generate-article` |
|
||||
| `download_settlement_amazon` | 非 kebab-case(下划线) |
|
||||
|
||||
---
|
||||
@@ -201,14 +218,14 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
- **平台前置**:`amazon-download-...`、`shopee-add-...`
|
||||
- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称
|
||||
- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步
|
||||
- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读
|
||||
- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等
|
||||
- **用 default 占位**:`generate-article-default`。没有外部平台就省略末段
|
||||
- **名词堆砌**:中间段过多导致超 48 字符或难以阅读
|
||||
|
||||
---
|
||||
|
||||
## 12. 演进说明(当前不启用)
|
||||
|
||||
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。
|
||||
当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**。新技能使用动词-名词;只有绑定外部平台时才使用 verb-noun-platform,或使用 scope 型。
|
||||
|
||||
---
|
||||
|
||||
@@ -218,9 +235,9 @@ slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对
|
||||
|
||||
| 历史 slug | 说明 |
|
||||
|-----------|------|
|
||||
| `account-manager` | 跨平台账号管理,非 verb-noun-platform |
|
||||
| `reconcile-finance` | 2 段无 platform |
|
||||
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) |
|
||||
| `account-manager` | 跨平台账号管理,非动词-名词 |
|
||||
| `reconcile-finance` | 现已是合法的无平台形态;旧文档曾把它标成反例 |
|
||||
| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家);新技能若要表达链家,先把平台加入白名单 |
|
||||
| `skill-template` | 模板仓库本身 |
|
||||
|
||||
校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。
|
||||
|
||||
@@ -18,16 +18,17 @@
|
||||
| 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-RELEASE-001 | 发布 tag(去掉前缀)必须等于 `SKILL.md` 的 `version:` 且等于 `scripts/util/constants.py` 的 `SKILL_VERSION`;`release.ps1` 打 tag 前硬失败,不自动改写字段 | development/DEVELOPMENT.md §15 发布;宿主市场「最新版本」来自 tag、「当前版本」来自包内 `SKILL.md` | hard | `release.ps1` 门禁 + 日常版本字段对齐测试 | `release.ps1`(`Assert-SkillReleaseVersionAlignment`);`tests/test_development_policy_guard.py::TestPolicyRelease001`;`tests/test_skill_metadata.py::test_skill_version_alignment` |
|
||||
| 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` |
|
||||
@@ -41,6 +42,7 @@
|
||||
| POLICY-SKILL-ACTION-002 | 每个 Action 必须显式声明 `executionProfile`,且只能是 `sync` 或 `async` | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema required + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction002`;`tests/test_actions_manifest.py` |
|
||||
| POLICY-SKILL-ACTION-003 | toolbar Action 必须声明合法非空 `bind.tables`(snake_case、唯一) | references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | schema if/then + manifest 扫描 | `tests/test_development_policy_guard.py::TestPolicySkillAction003`;`tests/test_actions_manifest.py` |
|
||||
| POLICY-SKILL-ACTION-004 | `placements` 与 `executionProfile` 正交;文档不得把数据管理入口与异步执行方式硬绑定;Schema 须接受 toolbar/row/batch/cron/agent/skill-detail × sync/async 全部组合 | development/SKILL_ACTION_RUNTIME.md;references/ACTIONS.md;assets/schemas/skill-actions.schema.json | hard | Schema 6×2 矩阵校验 + 文档禁止短语扫描 | `tests/test_actions_manifest.py::test_schema_allows_all_placement_execution_profile_combinations`;`tests/test_development_policy_guard.py::TestPolicySkillAction004` |
|
||||
| POLICY-TUTORIAL-001 | 存在 `development/TUTORIAL_SURFACES.md`;`TUTORIAL.md` 须含数据管理/对话/定时/对照表骨架;`README.md` 禁止「告诉 Agent」专章与入口专章;说明/教程正文禁止与页头重复的 `#` H1(主分节用 `##`);业务技能教程与 `actions.json` placements 对齐(无 cron 须声明不需要定时) | development/TUTORIAL_SURFACES.md;development/CHECKLIST.md A2-7~A2-12 | hard | 文档存在性 + 章节/反模式扫描 + placements 对齐 | `tests/test_tutorial_surfaces.py`;`tests/test_docs_standards.py::test_tutorial_surfaces_contract_doc_and_checklist` |
|
||||
| POLICY-ARCH-CORE-001 | 同一业务能力的 Action、CLI、Agent、cron、数据管理入口复用同一业务内核(文档要求已声明) | development/DEVELOPMENT.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;真实复用靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyArchCore001`;`tests/samples/test_action_core_contract.py.sample` |
|
||||
| POLICY-DATA-SYNC-001 | 同步型业务须遵循幂等、事务、完整性与空结果保护(文档已声明) | references/SCHEMA.md;development/SKILL_ACTION_RUNTIME.md | hard(文档标记) / soft(语义) | 文档关键表述存在性;完整语义靠 sample/review | `tests/test_development_policy_guard.py::TestPolicyDataSync001`;`tests/samples/test_sync_contract.py.sample` |
|
||||
|
||||
@@ -58,12 +60,17 @@
|
||||
| 是否满足完整快照语义 / 空结果保护实现 | references/SCHEMA.md | 见 `test_sync_contract.py.sample` |
|
||||
| 是否把系统导出与业务同步混淆 | references/ACTIONS.md;SCHEMA.md | 文档 + sample;勿做源码关键词误报扫描 |
|
||||
| `db/` 层不得调浏览器或拼业务流程 | development/DEVELOPMENT.md §11 | 需跨文件调用图 |
|
||||
| slug 命名语义(verb-noun-platform) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
|
||||
| slug 命名语义(动词-名词,平台可选) | development/NAMING.md | 部分覆盖于 `tests/test_slug_naming.py`,非本矩阵 hard policy |
|
||||
| 复制模板不得保留 `.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 | 待宿主升级后完整生效;模板侧已强制显式绑定 |
|
||||
| form +「已选择」时映射 `readOnly` 默认不占参数区(仍提交);`revealInForm` 逃生阀 | references/ACTIONS.md「选择摘要」 | soft(宿主 UX);文档与 schema 已声明;勿用 `visibleWhen` 代替 |
|
||||
| 数据管理技能按钮中文名(`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 已约束 |
|
||||
|
||||
188
development/PRODUCT_SURFACES.md
Normal file
188
development/PRODUCT_SURFACES.md
Normal file
@@ -0,0 +1,188 @@
|
||||
# 宿主产品入口(对话 / 数据管理 / 定时任务)
|
||||
|
||||
本文给**技能开发者**与 **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. 用户教程如何投影本文
|
||||
|
||||
入口定位表填完后,**必须**落到用户市场文档(见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)):
|
||||
|
||||
| 定位结果 | `TUTORIAL.md` | `README.md` |
|
||||
|----------|---------------|-------------|
|
||||
| 挂数据管理 | 「入口一」专章 + 表/按钮步骤 | 仅一句「支持数据管理」 |
|
||||
| 挂对话 | 「入口二」专章 + 话术 | 仅一句「支持对话」;**禁止**话术块 |
|
||||
| 挂定时 | 「入口三」教怎么建 | 可选一句边界 |
|
||||
| 不挂定时 | 「入口三」写不需要 + 理由 | 可选一句「不提供定时」 |
|
||||
|
||||
准备/载入类 Action:优先 `toolbar` + `bind.tables`,避免教程写「技能详情点按钮」而宿主详情页不渲染。
|
||||
|
||||
---
|
||||
|
||||
## 8. 相关文档
|
||||
|
||||
- 需求里填定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md)
|
||||
- **用户说明 / 教程怎么写:** [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)
|
||||
- 交活怎么验:[`CHECKLIST.md`](CHECKLIST.md)
|
||||
- 开发步骤中的 Action 挂载:[`DEVELOPMENT.md`](DEVELOPMENT.md) §11.5、§15
|
||||
- Agent 侧字段契约:[`../references/ACTIONS.md`](../references/ACTIONS.md)
|
||||
@@ -9,13 +9,16 @@
|
||||
| 步骤 | 做什么 | 详见 |
|
||||
|------|--------|------|
|
||||
| 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 总纲**;界面技能先完成元素拾取/element map,再按浏览器、桌面或手机读分端手册 | [`AI_RPA.md`](AI_RPA.md)、[`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.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) |
|
||||
| 2b | **用户市场文档**:说明 Tab ≠ 教程 Tab;教程按三入口写;与 §0.1 / actions 对齐 | [`TUTORIAL_SURFACES.md`](TUTORIAL_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」 |
|
||||
| 6b | **按需配置 `brands`(OEM 市场)**:须在华商等 OEM 客户端出现时声明 `brands`(如 `jiangchang` + `huashang`);未写则仅挂匠厂。Gitea 组织名不会自动映射市场 | [`DEVELOPMENT.md`](DEVELOPMENT.md) §6「关于 brands」 |
|
||||
| 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,23 +28,25 @@
|
||||
|
||||
开始定制 skill 前,建议按以下顺序阅读:
|
||||
|
||||
1. [`AI_RPA.md`](AI_RPA.md) — **AI Agent + RPA 总纲**:职责边界、合规红线、风控、操作节奏与完整闭环
|
||||
2. [`ACCOUNT_MANAGER.md`](ACCOUNT_MANAGER.md) — **浏览器账号标准**:一账号一 Profile、租约、多账号、数据查看、验收与排障
|
||||
3. [`SHARED_REPOS.md`](SHARED_REPOS.md) — **公共仓库目录**(模板 / 账号管理 / platform-kit 等,可扩展)
|
||||
4. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准
|
||||
5. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读)
|
||||
6. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范
|
||||
7. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关
|
||||
8. [`LOGGING.md`](LOGGING.md) — 日志分层、必打节点、敏感信息红线(**涉及长任务、RPA、外部系统时必读**)
|
||||
9. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时
|
||||
10. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时的会话、风控和工程总纲
|
||||
11. [`RPA_ELEMENT_LOCATING.md`](RPA_ELEMENT_LOCATING.md) — **所有界面技能必读**:真实拾取、element map、唯一性、降级和 AI 编程约束
|
||||
12. 按执行端选读:[`RPA_BROWSER.md`](RPA_BROWSER.md) / [`RPA_DESKTOP.md`](RPA_DESKTOP.md) / [`RPA_MOBILE.md`](RPA_MOBILE.md)
|
||||
13. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制
|
||||
14. [`DATA_PATHS.md`](DATA_PATHS.md) — 下载/导入/导出等本地文件路径标准(涉及文件读写时必读)
|
||||
15. [`SKILL_ACTION_RUNTIME.md`](SKILL_ACTION_RUNTIME.md) — Skill Action sync/async、任务中心、Agent 禁令、队列 pick(涉及 RPA / 长任务 / 数据管理按钮 / Cron / Agent 时必读)
|
||||
16. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定
|
||||
17. [`CHECKLIST.md`](CHECKLIST.md) — **交活验收清单(第三方视角)**;报告输出到 [`../acceptance-reports/`](../acceptance-reports/)
|
||||
1. [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) — **宿主产品入口**:对话 / 数据管理 / 定时任务平级;何时走哪个;入口定位表
|
||||
1b. [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md) — **用户说明 / 教程契约**:README≠TUTORIAL;三入口结构;skill-detail 不可作主路径
|
||||
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),不要写进本目录。
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
- 中文 description(一句话):
|
||||
- account-manager platform_key(如有):
|
||||
- 命名形态:标准型 / scope 型
|
||||
- **目标 OEM 市场 `brands`(可选)**:未写则发布后仅挂匠厂(`jiangchang`)。须在华商等 OEM 客户端可见时填写,例如 `jiangchang`、`huashang`(见 `DEVELOPMENT.md` §6「关于 brands」)。**不要**用 Gitea 组织名代替本字段。
|
||||
- **网页 RPA(如有)— 登录策略(三选一,须写明):**
|
||||
- `required`:目标站必须登录才能闭环(用户须完成/保持站点登录;技能每轮主路径须有登录门禁 + 验证码/人工等待)
|
||||
- `optional`:登录可提升能力,但不登录也能完成主路径(**仅当**公开页确可闭环时选用;会话依赖型采集/评论/私信**禁止**默认抄成 optional)
|
||||
@@ -31,7 +32,30 @@
|
||||
- **登记 ≠ 登录**:ensure/add-web 只建账号与 Profile;站点登录由本技能在有头浏览器中完成
|
||||
- **反模式**:把「需要登录态才能采到数据」的需求写成 `optional`,再省略 `ensure_logged_in`
|
||||
|
||||
## 0.1 AI + RPA 协作与授权边界(涉及外部系统时必填)
|
||||
## 0.1 宿主入口定位(必填)
|
||||
|
||||
先读 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。对话任务 / 数据管理 / 定时任务在宿主上**平级**;挂哪些由本技能业务与 UX 定,模板不规定「必须三入口都挂满」。用户文档如何写见 [`TUTORIAL_SURFACES.md`](TUTORIAL_SURFACES.md)。
|
||||
|
||||
对每个主能力填一行(可增行;「不挂」必须写理由):
|
||||
|
||||
| 主能力(Action id / CLI) | 对话 | 数据管理 | 定时 | 教程章节 | 不挂理由(若不挂) |
|
||||
|---------------------------|------|----------|------|----------|--------------------|
|
||||
| (例)`<action-or-cli>` | 挂 / 不挂 | 不挂 / toolbar / row / batch / replaces / 仅展示表 | 挂 / 不挂 | 入口一 / 入口二 / 入口三 / N/A | |
|
||||
|
||||
说明:
|
||||
|
||||
- 数据管理列可写组合(如 `toolbar + row`)
|
||||
- 「仅展示表」= 用户要看过程/结果,但不在表上触发该能力
|
||||
- 定位为「挂」的入口须在 `assets/actions.json`(及必要表元数据)中落实,并与对话路径共用同一 domain service
|
||||
- **教程章节**:该能力在 `TUTORIAL.md` 中落在哪一节;整技能无定时时,入口三仍须写「不需要 + 理由」
|
||||
|
||||
### 0.1.1 用户市场文档(与入口表一致)
|
||||
|
||||
- `README.md`(说明 Tab):只写能力 / 场景 / 边界;**禁止**分步操作与 Agent 话术(见 `TUTORIAL_SURFACES.md` §1)
|
||||
- `TUTORIAL.md`(教程 Tab):覆盖 §0.1 中所有「挂」的入口;准备/载入类优先数据管理 toolbar,**不要**把 skill-detail 当主路径
|
||||
- 定位为「不挂」定时:教程须有 N/A 声明,与本表理由一致
|
||||
|
||||
## 0.2 AI + RPA 协作与授权边界(涉及外部系统时必填)
|
||||
|
||||
### Agent 设计
|
||||
|
||||
@@ -69,6 +93,20 @@
|
||||
- 需要保存哪些日志、截图、视频和业务结果:
|
||||
- 定位失败时如何保存 DOM/UIA/XML、候选数量和环境版本:
|
||||
|
||||
## 0.3 业务行生命周期(有「处理某一行」类能力时必填)
|
||||
|
||||
若本技能存在对业务表某一行做生成、触达、私信、发布等**有副作用**的处理,须遵守 [`../references/WORK_ITEM_STATUS.md`](../references/WORK_ITEM_STATUS.md),并在此声明**本技能状态 → 角色**映射(状态名自定,角色固定):
|
||||
|
||||
| 角色 | 本技能状态值(可多个) | 说明 |
|
||||
|------|------------------------|------|
|
||||
| 可操作(operable) | (例)新建 / 待生成 / … | 默认唯一可被处理 Action 捞起 |
|
||||
| 进行中(in_progress) | (例)生成中 / 触达中 / … | 抢占后、结束前 |
|
||||
| 成功类终态 | (例)已生成 / 已私信 / … | 默认不可再处理 |
|
||||
| 失败类终态 | (例)失败 / 触达失败 / … | 默认不可再处理;失败原因字段: |
|
||||
| 跳过类终态 | (例)已跳过 / … | 默认不可再处理 |
|
||||
|
||||
无此类处理队列时整节写「不适用」即可。
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
说明这份需求文档的作用,以及它解决什么问题。
|
||||
@@ -86,7 +124,7 @@
|
||||
|
||||
- 当前用户在 `<业务场景>` 中需要反复处理 `<输入数据>`,人工操作耗时且易错。
|
||||
- 现有流程主要依赖人工处理,存在 `<效率低 / 容易出错 / 不可批量 / 不可追踪>` 等问题。
|
||||
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`,并写入 `task_logs` 便于追踪。
|
||||
- 因此需要开发 `your-skill-slug`,用于自动完成 `<核心动作>`,输出 `<输出结果>`。
|
||||
|
||||
填写提示:
|
||||
|
||||
@@ -104,7 +142,7 @@
|
||||
- 解析并校验 `<输入数据>` 的关键字段
|
||||
- 通过 adapter 完成 mock / simulator / real 档位下的 `<核心动作>`
|
||||
- 输出结构化结果(stdout JSON 或固定文本 + 机读字段)
|
||||
- 写入 `task_logs`(及必要的业务表,见 `references/SCHEMA.md`)
|
||||
- 写入必要的业务表(见 `references/SCHEMA.md`;无业务表也可)
|
||||
- 支持失败可诊断(稳定错误码、`ERROR:` 前缀、必要截图/日志)
|
||||
- 完成 CLI 入口与 `health` / `version` / `init-db` 最小可运行验证
|
||||
- 完成正式环境发布与安装验证(如适用)
|
||||
@@ -116,7 +154,7 @@
|
||||
模板写法:
|
||||
|
||||
- 支持 `<命令1>`(如 `run`)
|
||||
- 支持 `<命令2>`(如 `logs` / `log-get`)
|
||||
- 支持 `<命令2>`(如 `health` / 业务只读查询)
|
||||
- 支持读取 `<数据来源>`(兄弟 skill / 本地 DB / 外部 API)
|
||||
- 支持执行 `<核心业务动作>`
|
||||
- 支持记录 `<日志 / 状态 / 结果>`
|
||||
@@ -130,13 +168,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. 非功能要求
|
||||
|
||||
@@ -207,14 +246,16 @@
|
||||
- `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)
|
||||
- 若目标含华商等 OEM 客户端:`SKILL.md` 的 `brands` 已声明对应 `market_code`(通常含 `jiangchang` + 目标 OEM);未写则仅匠厂市场可见
|
||||
- 发布后 Gitea 工作流成功;匠厂技能市场对开发者账号可见最新版本并可安装
|
||||
- 安装后按本技能 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
|
||||
- 若声明了 OEM `brands`:在对应 OEM 客户端(`X-Market-Code` 正确)也能检索到该技能
|
||||
- 安装后按本技能入口定位表与 `actions.json` 声明完成宿主多入口验收(见 `development/DEVELOPMENT.md` §15、`PRODUCT_SURFACES.md`);**须看用户体验**(进度可见、失败可读、RPA 有头、文案与真实行为一致等),后台通了不算完
|
||||
- 已人工核验授权与技术路线:无破解、逆向、私有协议、验证码绕过、Hook / 注入、自动换号或其他规避平台限制的实现
|
||||
- RPA 真实主路径已验证状态等待、`1~3` 秒正常操作节奏、有限重试、账号 / 设备串行、人工介入、结果核验和结束存证
|
||||
- RPA 关键元素已在真实界面拾取并登记 element map;关键动作唯一定位,重启会话后复测通过
|
||||
@@ -259,19 +300,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. 非功能要求
|
||||
|
||||
@@ -288,7 +328,7 @@
|
||||
|
||||
- 成功 JSON / 摘要
|
||||
- ERROR: 稳定错误码
|
||||
- task_logs 记录
|
||||
- 任务中心终态(`finish`)
|
||||
|
||||
## 7. 依赖的兄弟技能或外部系统
|
||||
|
||||
@@ -303,9 +343,9 @@
|
||||
## 9. 验收标准
|
||||
|
||||
- 命令可运行;tests/run_tests.py -v 通过
|
||||
- task_logs 符合 SCHEMA
|
||||
- 业务表(若有)符合 SCHEMA;过程看运行日志,进度看任务中心
|
||||
- `developer_ids` 已配置为「你的用户ID」(匠厂设置中复制的正整数)
|
||||
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 自测
|
||||
- 正式环境安装验证通过;已声明的宿主入口(新建任务 / 数据管理 / 定时任务 / 任务中心 / 技能详情等)按 DEVELOPMENT §15 与入口定位表自测
|
||||
## 10. 开发注意事项
|
||||
|
||||
- 不修改无关项目;不引入旧模板结构
|
||||
|
||||
@@ -99,6 +99,16 @@ async def open_login(page):
|
||||
|
||||
**状态等待优先:**页面加载、控件可用、弹窗消失、保存成功、下载完成和业务结果出现,必须等待真实条件并设置超时;禁止用固定 `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` 秒。
|
||||
@@ -359,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. 存证与录屏规范
|
||||
@@ -402,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()`。
|
||||
|
||||
|
||||
@@ -15,14 +15,16 @@
|
||||
|
||||
## 1. 已验证基线
|
||||
|
||||
真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`。
|
||||
真实成功来源:`D:\OpenClaw\client-marketing\add-friend-wecom`(含通讯录 → 新的客户 → 添加 → 搜索 → 发送全链路)。
|
||||
|
||||
该案例验证了:
|
||||
|
||||
- 通过进程/窗口类名/标题找到企业微信顶层窗口。
|
||||
- 从 HWND 获取 UIA 根控件。
|
||||
- 在父窗口和弹窗范围内按 UIA 属性定位。
|
||||
- 真实激活窗口、点击、逐字输入和轮询状态。
|
||||
- 主窗激活后**最大化**,避免侧栏折叠导致控件不可见或坐标漂移。
|
||||
- 从 HWND 获取 UIA 根控件;在**子业务 pane**(如「企业微信-通讯录」)内缩小作用域。
|
||||
- 区分 UIA `Name` 与 `Value`:可见中文常在 Value / 子 Label,不在 Name。
|
||||
- 通用叶子名(`item` / `status_layout` / `wbtn_container`)必须绑业务父容器,并用文案校验。
|
||||
- 真实点击后校验后置状态(标题、专用容器、子窗口)。
|
||||
- 处理独立弹窗与嵌套弹窗。
|
||||
- 提供 `probe-ui` 诊断和 `dry-run / assisted / auto` 三档。
|
||||
|
||||
@@ -34,31 +36,37 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
|
||||
|
||||
操作步骤:
|
||||
|
||||
1. 启动目标软件,登录并手工进入准确页面。
|
||||
1. 启动目标软件,登录并手工进入准确页面;需要完整侧栏时先**最大化**。
|
||||
2. 记录目标进程名、PID、主窗口标题和 Window Class。
|
||||
3. 用检查器点选目标元素,记录:
|
||||
- AutomationId
|
||||
- Name
|
||||
- ControlType
|
||||
- ClassName
|
||||
- FrameworkId
|
||||
- IsEnabled / IsOffscreen
|
||||
- BoundingRectangle
|
||||
- 父容器链
|
||||
- **Name**(内部 id,可能是 `org_op_new` / `status_layout`)
|
||||
- **Value**(可见中文文案常在这里,如「通讯录」「新的客户」)
|
||||
- ControlType / ClassName / FrameworkId
|
||||
- IsEnabled / IsOffscreen / BoundingRectangle
|
||||
- 父容器链(含业务子窗/pane 标题)
|
||||
4. 保存截图和原始 UIA 树/拾取器导出文件。
|
||||
5. 从长路径中找出稳定窗口、业务父容器和叶子属性。
|
||||
6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据。
|
||||
7. 写入 element map。
|
||||
8. 运行 `probe-ui`,验证关键窗口和元素。
|
||||
6. 删除仅反映当前布局的深层 index;确需 index 时写明业务依据,且优先用文案定位。
|
||||
7. 写入 element map(Name 与 Value 分开登记)。
|
||||
8. 运行 `probe-ui`(建议先激活并最大化),验证关键窗口和元素。
|
||||
9. 关闭重开软件、切换一次页面后再次验证。
|
||||
|
||||
### Name / Value(易错)
|
||||
|
||||
- 影刀/检查器里的**选择器显示名**(如「新的客户」)≠ 控件 UIA `Name`。
|
||||
- 许多国产客户端:`Name` 是内部 id,**界面中文在 `Value` 或子控件 Value**。
|
||||
- 拾取时两者都记;定位时勿把业务中文当成 `Name=` 去搜。
|
||||
|
||||
### 从拾取结果提炼定位器
|
||||
|
||||
影刀等工具可能导出很长的 UIA 路径。不要整条照抄:
|
||||
|
||||
```text
|
||||
原始:主窗口 → 多层布局 → 列表 → item[2] → status_layout
|
||||
提炼:进程/窗口 → 通讯录列表父容器 → 业务文案或稳定叶子属性
|
||||
原始:主窗口 → 通讯录 pane → wlist_view → item[2] → status_layout
|
||||
→ … → main_chattext(Value=新的客户)
|
||||
提炼:通讯录 pane → wlist_view → 行内 main_chattext.Value=新的客户 → 点击该行 status_layout
|
||||
禁止:全树第一个 Name=status_layout(常为「群聊」等其它行)
|
||||
```
|
||||
|
||||
长路径用于排障和理解结构;生产定位器使用最短、唯一、可解释的稳定组合。
|
||||
@@ -67,25 +75,28 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
|
||||
|
||||
从高到低:
|
||||
|
||||
1. `Process/PID + WindowClass + WindowTitle`
|
||||
1. `Process/PID + WindowClass + WindowTitle`(及稳定业务子 pane)
|
||||
2. 目标窗口内 `AutomationId + ControlType`
|
||||
3. 稳定 `Name + ControlType`
|
||||
3. 稳定内部 `Name + ControlType`(如 `org_op_new`、`searchedit`)
|
||||
4. 业务父容器 + 稳定子元素属性
|
||||
5. 可见业务文案 + 相对关系
|
||||
5. **可见业务文案(优先读 Value / 子 Label)+ 相对关系**
|
||||
6. `ClassName/FrameworkId` 辅助组合
|
||||
7. 有业务依据的 index
|
||||
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或明确窗口信息选择,歧义时停止。
|
||||
@@ -102,11 +113,13 @@ example 只提炼通用模式;企业微信名称、窗口类、控件名、列
|
||||
|
||||
### 点击
|
||||
|
||||
1. 激活目标窗口。
|
||||
1. 激活目标窗口(主窗按需最大化;子对话框不要误 Maximize)。
|
||||
2. 重新定位元素。
|
||||
3. 检查边界矩形。
|
||||
4. 使用 UIA Click/Invoke 或正常鼠标点击。
|
||||
5. 等待后置窗口/控件/业务状态。
|
||||
5. 等待后置窗口/控件/业务状态(如标题文案、专用容器、子窗口 Class)。
|
||||
|
||||
关键导航建议日志:`locate`(策略/文案)→ `click` → `verify`(后置)。这与 [`LOGGING.md`](LOGGING.md)「不必每个 Click 一行」不冲突:要求的是**关键跳转可观测**,不是像素级刷屏。
|
||||
|
||||
UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降级,但必须记录降级、确认窗口前台并验证后置状态;不得静默改点写死坐标。
|
||||
|
||||
@@ -133,10 +146,11 @@ UIA 点击失败时可以用该元素刚读取的 BoundingRectangle 中心点降
|
||||
先检查:
|
||||
|
||||
1. 权限级别和 Windows 会话是否一致。
|
||||
2. 是否选错进程或子窗口。
|
||||
2. 是否选错进程或子窗口;主窗是否未最大化导致侧栏未实例化。
|
||||
3. 控件是否离屏或尚未实例化。
|
||||
4. 应用是否为 Electron(可评估 Playwright)。
|
||||
5. 是否为自绘、游戏引擎、远程桌面或安全控件。
|
||||
6. 空树/树抖动时:最大化主窗;用 Inspect/影刀附着一次后再 probe(远程桌面工具可能干扰无障碍树)。
|
||||
|
||||
只有控件树确认不可用时才使用图像:
|
||||
|
||||
@@ -153,11 +167,15 @@ U盾、人脸、动态口令和安全键盘交给人工,不用 OCR、Hook 或
|
||||
桌面 `health` / `probe-ui` 至少输出:
|
||||
|
||||
- UIA 依赖是否可用。
|
||||
- 目标进程和主窗口是否存在。
|
||||
- 目标进程和主窗口是否存在;是否已最大化(若业务需要)。
|
||||
- 关键窗口/控件是否各唯一匹配。
|
||||
- 当前窗口标题、Class、PID。
|
||||
- DPI/缩放诊断。
|
||||
|
||||
example 里的最小 `probe`(只探主窗+个别入口)**不等于**生产 probe 完整度;业务技能应对关键跳转节点分别探测或做深探。
|
||||
|
||||
手开 PowerShell 跑 `stats` / `health` / dump 时,必须显式对齐宿主的 `CLAW_USER_ID` / 数据根,否则会读到默认用户空库,误判熔断/冷却。
|
||||
|
||||
定位失败保存:
|
||||
|
||||
- 目标窗口和全屏截图。
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
|
||||
未完成第 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:
|
||||
@@ -155,7 +157,7 @@ verified_at: "YYYY-MM-DD"
|
||||
|
||||
1. 先阅读本总标准和对应分端手册。
|
||||
2. 先输出业务步骤表和元素清单,再生成代码。
|
||||
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。
|
||||
3. 只使用真实拾取得到的属性;缺少属性时明确停止并索要界面快照。桌面端须同时记录 **Name 与 Value**(可见中文常在 Value;见 [`RPA_DESKTOP.md`](RPA_DESKTOP.md) §2)。
|
||||
4. 把目标系统 selector 集中到 element map/selector catalog,不散落在业务流程中。
|
||||
5. 为每个动作实现前置状态、定位、动作和后置状态。
|
||||
6. 把诊断探针、界面树导出和失败截图作为实现的一部分。
|
||||
|
||||
@@ -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` 来调整顺序。
|
||||
|
||||
@@ -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`、`bind.selectionMode`、**用户可见文案**、inputSchema、跨表 Lookup、字段条件显隐 `visibleWhen`、本地路径 `file`/`directory` + 可选 **`pathRoot`**、选择摘要「已选择」与映射 **`readOnly` / `revealInForm`**、**按钮中文名 `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` 混淆;**勿**用其藏映射主键。可选 **`pathRoot`**(见 ACTIONS.md):`file`/`directory` 原生选路对话框起始目录(相对技能数据根;省略默认 `{skill_data}/input`)。可选 **`bind.labelColumn`**(见 ACTIONS.md):选择摘要可读标签列;省略 = 宿主常见列名启发。可选 **`bind.selectionMode`**(见 ACTIONS.md):表勾选如何驱动 Action(`required` / `none` / `optional`);省略 = 宿主按 `$selection.ids` / `batch` 推断。可选 **`revealInForm`**(见 ACTIONS.md):有「已选择」时仍强制显示某 `readOnly` 字段;省略 = 映射只读默认不占 form 参数区(仍提交)。可选 **`cellPresentation`**(见 `references/CELL_PRESENTATION.md`):单元格/整行/整列/区域语义标色;省略 = P0。**用户可见文案**(见 ACTIONS.md「用户可见文案」):弹窗 `description` / 确认黄条 / 参数说明 / 任务失败 `message` 须全中文产品用语;Agent 路由勿塞进弹窗。
|
||||
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 页面报告 | 壳子页 `assets/html/pages/*.html`;快照写入 `{skill_data}/html/*.html` | 壳子装完即现;快照由技能生成,重新生成 Action id 固定为 **`render-html`**。页面用 `jiangchang.call` 调已声明命令 |
|
||||
|
||||
同步 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)。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -22,19 +22,20 @@
|
||||
|
||||
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
|
||||
|
||||
- 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`);
|
||||
- **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。
|
||||
- **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。
|
||||
- **开发规范守护(hard policy)**:[`tests/test_development_policy_guard.py`](../tests/test_development_policy_guard.py) ——规则索引见 [`development/POLICY_MATRIX.md`](POLICY_MATRIX.md),只执行可机器检测的 hard 规则。
|
||||
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。
|
||||
- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——动词-名词命名;平台可选,`default` 不再作为占位。
|
||||
- **HTML 报告壳**:[`tests/test_html_page_service.py`](../tests/test_html_page_service.py) 守护 `assets/html/example-report.html` 的数据管理 iframe 预览布局基线,并守护 ACTIONS.md 里的壳子页目录 `assets/html/pages` 与 `jiangchang.call`。业务技能若有自定义 `assets/html/*.html` 或 `pages/*.html`,建议在自有仓库加同类静态断言(见 `references/ACTIONS.md`「宿主预览布局」)。不要把 `example-report.html` 放进 `pages/`。
|
||||
|
||||
**原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。
|
||||
|
||||
@@ -198,7 +199,7 @@ 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`)
|
||||
@@ -242,7 +243,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 套件。
|
||||
@@ -265,7 +266,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`
|
||||
|
||||
222
development/TUTORIAL_SURFACES.md
Normal file
222
development/TUTORIAL_SURFACES.md
Normal file
@@ -0,0 +1,222 @@
|
||||
# 用户市场文档契约(说明 Tab / 教程 Tab)
|
||||
|
||||
本文给**技能开发者**与 **AI 编程助手**读:规定根目录 `README.md`(技能说明)与 `TUTORIAL.md`(使用教程)**写什么、不写什么**,以及如何与宿主三入口、入口定位表、`actions.json` 对齐。
|
||||
|
||||
**单一真相链:**
|
||||
|
||||
```text
|
||||
REQUIREMENTS.md §0.1 入口定位表
|
||||
↓
|
||||
assets/actions.json(placements / label / bind)
|
||||
↓
|
||||
TUTORIAL.md(用户可读、分入口操作)
|
||||
README.md(能力与边界;不写步骤)
|
||||
```
|
||||
|
||||
产品入口「挂什么」见 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)。本文只管**用户市场文档怎么投影**。
|
||||
|
||||
---
|
||||
|
||||
## 0. 与四 Tab 的关系
|
||||
|
||||
| 文件 | 市场 Tab | 本文角色 |
|
||||
|------|----------|----------|
|
||||
| `README.md` | **说明** | §1:是什么 / 给谁 / 边界 |
|
||||
| `TUTORIAL.md` | **教程** | §2~§6:怎么用(分入口) |
|
||||
| `DEMO.md` | 演示 | 观看顺序可对齐三入口,不另造标准 |
|
||||
| `CHANGELOG.md` | 更新日志 | 与文档契约无关 |
|
||||
|
||||
**铁律:** 说明就是说明,教程就是教程。**禁止**在 README 里写分步操作、Agent 话术块、排障「点哪里」;这些一律进 `TUTORIAL.md`。
|
||||
|
||||
### 0.1 标题层级(说明 / 教程共用)
|
||||
|
||||
宿主详情页**已经**展示技能名;「说明 / 使用教程」Tab(及数据管理右侧教程抽屉)**已经**标明文档类型。因此:
|
||||
|
||||
| 层级 | 用途 |
|
||||
|------|------|
|
||||
| `#` | **正文不要用**(会与页头/Tab 标题重复抢视觉) |
|
||||
| `##` | 主分节(能帮你做什么、入口一:数据管理…) |
|
||||
| `###` | 分节内步骤 |
|
||||
|
||||
**禁止:**
|
||||
|
||||
- `README.md` 文首 `# 【技能名】` / `# 审计疑点辅助` 这类与页头重复的 H1
|
||||
- `TUTORIAL.md` 文首 `# 【技能名】· 使用教程` 这类与 Tab/抽屉标题重复的 H1
|
||||
|
||||
去掉重复 H1 后,**不要**把原来的 `##` 全部升成 `#`。
|
||||
|
||||
---
|
||||
|
||||
## 1. README.md(说明 Tab)
|
||||
|
||||
### 1.1 必须回答
|
||||
|
||||
| 问题 | 写法 |
|
||||
|------|------|
|
||||
| 这是什么 | 开篇一两句价值(**普通段落或引用**,不要再写技能名 H1) |
|
||||
| 能做什么 | 3~6 条能力 bullet(**无步骤编号**) |
|
||||
| 适合谁 / 场景 | 业务语言 |
|
||||
| 使用前准备 | 安装、账号、权限等**前置条件**;不写「去哪张表点哪」 |
|
||||
| 执行后得到什么 | 结果形态(表、报告、文件),**不教怎么生成** |
|
||||
| 注意事项 | 边界、风险、人工复核 |
|
||||
| 怎么使用(可选,短) | **一两句**:支持哪些入口 + **「步骤见使用教程 Tab」** |
|
||||
|
||||
### 1.2 禁止写入 README
|
||||
|
||||
- `1.` `2.` `3.` 操作步骤(如「选品任务 → 点某某按钮」)
|
||||
- 「你可以这样告诉 Agent」整节或大块 `>` 示例话术
|
||||
- 「入口一 / 入口二」专章结构
|
||||
- 逐步排障(「先点 A 再点 B」)
|
||||
- frontmatter `description` 不要写「以及如何开始使用」类教程承诺
|
||||
|
||||
### 1.3 FAQ 边界
|
||||
|
||||
- README FAQ:**概念类**(和真实系统区别、从哪开始学、是否支持定时)→ 可指向「使用教程」Tab
|
||||
- **操作类**排障 → 只写在 `TUTORIAL.md`「常见卡住怎么办」
|
||||
|
||||
### 1.4 建议章节(标题可微调)
|
||||
|
||||
```text
|
||||
(开篇一两句价值;不要写 # 技能名)
|
||||
## 这个技能能帮你做什么
|
||||
## 适合什么场景
|
||||
## 使用前需要准备什么
|
||||
## 怎么使用 ← 仅短摘要 + 指向教程
|
||||
## 执行后你会得到什么
|
||||
## 注意事项
|
||||
## 常见问题 ← 概念类
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. TUTORIAL.md(教程 Tab)必选结构
|
||||
|
||||
复制业务技能后,**验收章节(标题可微调,语义不可缺)**:
|
||||
|
||||
```text
|
||||
(开篇:数据管理与对话平行;定时有/无一句。不要写 # 技能名 · 使用教程)
|
||||
|
||||
## 本技能做什么 ← 业务数据流(非 CLI/SQL)
|
||||
## 开始前
|
||||
## 入口一:数据管理 ← §0.1 有 toolbar/row/batch/replaces 则必填
|
||||
## 入口二:对话任务 ← §0.1 有 agent 则必填
|
||||
## 入口三:定时任务 ← 有 cron:怎么用;无 cron:不需要 + 理由
|
||||
## 主能力对照表 ← 双挂能力必填
|
||||
## 常见卡住怎么办
|
||||
```
|
||||
|
||||
### 2.1 与入口定位表的关系
|
||||
|
||||
| §0.1 / actions | TUTORIAL 要求 |
|
||||
|----------------|---------------|
|
||||
| 任一能力挂数据管理(toolbar/row/batch/replaces) | **必须**有「入口一:数据管理」专章 |
|
||||
| 任一能力挂 `agent` | **必须**有「入口二:对话任务」专章(**禁止**用「或者你也可以说…」替代专章) |
|
||||
| 任一能力挂 `cron` | **必须**有「入口三」且写清如何建定时、选哪个 Action |
|
||||
| **无** `cron` | **必须**写「本技能不需要定时」+ 一句理由(与 §0.1 一致) |
|
||||
| 某能力只挂一端 | 对照表注明「仅数据管理」或「仅对话」 |
|
||||
|
||||
### 2.2 平行关系(数据管理 ∥ 对话)
|
||||
|
||||
- 两者是**平行主入口**,不是「对话主、点表辅」或反过来
|
||||
- 禁止全文只有「第一步:打开对话说清目标」且无数据管理专章
|
||||
- 允许用户只用其中一种;教程须写清「两种效果一致,选你习惯的」
|
||||
|
||||
---
|
||||
|
||||
## 3. 入口一:数据管理(怎么写)
|
||||
|
||||
适合写:
|
||||
|
||||
1. 左侧有哪些业务表、各干什么(业务语言)
|
||||
2. 首次准备(载入/导入/新建)——**按钮必须出现在用户能看到的地方**(见 §5)
|
||||
3. 主流程:进哪张表 → 是否要选中行 → 点哪个 `label` → 去哪看结果
|
||||
4. 行级 / 批量操作(若有 `row` / `batch`)
|
||||
|
||||
按钮中文名 = `assets/actions.json` 的 `label`,不要另造别名。
|
||||
|
||||
---
|
||||
|
||||
## 4. 入口二:对话任务(怎么写)
|
||||
|
||||
适合写:
|
||||
|
||||
1. 什么时候用对话(与数据管理互补)
|
||||
2. 示例话术表(与主能力一一对应)
|
||||
3. Agent 可能追问什么、用户怎么答
|
||||
|
||||
**不要**把对话写成唯一路径;也不要把话术塞进 README。
|
||||
|
||||
---
|
||||
|
||||
## 5. 准确性铁律(用户看得见才写)
|
||||
|
||||
教程步骤必须对应 **宿主真实 UI**,不是只对应 manifest 声明。
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **placements 对齐** | 教程写的入口 ∈ `actions.json` 已挂 placement |
|
||||
| **skill-detail 不可作主路径** | 声明了 `skill-detail` **不等于**详情页一定渲染出按钮;当前宿主常只显示四 Tab。**禁止**把「技能详情 → 点某某」写成用户主路径 |
|
||||
| **准备/载入/导入类** | 优先挂 **数据管理 `toolbar`** + `bind.tables`(空表也可点的能力尤其如此);可保留 `agent`;`skill-detail` 仅作可选兼容 |
|
||||
| **写入后刷新** | 建议 `steps: [{ "when": "afterSuccess", "do": "refreshCatalog" }]`,避免用户点完表不刷新 |
|
||||
| **对照表** | 「想做什么 \| 数据管理 \| 对话」覆盖双挂主能力;`label` 与界面一致 |
|
||||
|
||||
字段细节见 [`../references/ACTIONS.md`](../references/ACTIONS.md)「用户可见性与教程准确性」。
|
||||
|
||||
---
|
||||
|
||||
## 6. 入口三:定时任务(可选)
|
||||
|
||||
### 6.1 何时挂 `cron`(教程教「怎么用定时」)
|
||||
|
||||
- 参数相对固定、按节奏重复(同步、巡检、消费队列)
|
||||
- 无人值守仍安全、可幂等或可失败重试
|
||||
- 与数据管理是「白天点选 + 夜里自动啃」组合
|
||||
|
||||
教程写:侧栏定时任务 → 新建 → 选本技能 → 选 Action → 填参 → 保存/立即运行。
|
||||
|
||||
### 6.2 何时不挂(教程写「不需要」)
|
||||
|
||||
- 一次性 / 课堂演示,对象当场指定
|
||||
- 强不可逆、须当场确认
|
||||
- 每次对象不同、须人指定链接或范围
|
||||
- 主路径依赖验证码 / 人工介入
|
||||
|
||||
决策三问(与 [`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md) §2.3 一致):
|
||||
|
||||
1. 明天人不盯着、同样参数再跑一遍,业务上合理吗?
|
||||
2. 失败后自动重跑可接受吗?
|
||||
3. 有没有须人当场确认或动态指定的输入?
|
||||
|
||||
①是 且 ②可接受 且 ③否 → 可挂 cron;否则不挂,§0.1 写理由,教程声明 N/A。
|
||||
|
||||
---
|
||||
|
||||
## 7. 复制后自检(发布前)
|
||||
|
||||
- [ ] README 无分步操作、无 Agent 话术块
|
||||
- [ ] TUTORIAL 有三入口结构(定时有或 N/A)
|
||||
- [ ] 对照表与 `actions.json` `label` 一致
|
||||
- [ ] 准备类按钮在数据管理表顶可见(若技能有数据管理主路径)
|
||||
- [ ] 教程未把 skill-detail 当唯一载入/主流程入口
|
||||
- [ ] §0.1「挂」的入口在 TUTORIAL 有专章;「不挂」有理由且教程一致
|
||||
|
||||
交活清单见 [`CHECKLIST.md`](CHECKLIST.md) A2-7~A2-11。
|
||||
|
||||
---
|
||||
|
||||
## 8. 参考样例
|
||||
|
||||
已验收参考(业务技能,非模板正文):`client-gdcm/review-sku-selection-default`
|
||||
(说明 Tab 瘦身 + 教程三入口 + 「载入课堂样例」挂 `selection_cases` toolbar。)
|
||||
|
||||
复制模板后**不要**照搬选品表名与样例编码;只复用**章节结构与职责划分**。
|
||||
|
||||
---
|
||||
|
||||
## 9. 相关文档
|
||||
|
||||
- 宿主入口何时挂:[`PRODUCT_SURFACES.md`](PRODUCT_SURFACES.md)
|
||||
- 入口定位表:[`REQUIREMENTS.md`](REQUIREMENTS.md) §0.1
|
||||
- Action placements / 可见性:[`../references/ACTIONS.md`](../references/ACTIONS.md)
|
||||
- 市场四 Tab 同步:[`DEVELOPMENT.md`](DEVELOPMENT.md) §6 附近「用户市场四 Tab」
|
||||
- 交活验收:[`CHECKLIST.md`](CHECKLIST.md) §2
|
||||
@@ -52,7 +52,7 @@ real_browser_rpa/
|
||||
8. **等待结果容器** — `#search-result-container`(示例 selector)
|
||||
9. **滚动采集循环** — 采集可见条目 → 判断 stop_reason → 滚动加载
|
||||
10. **返回结果** — `ScrapeRunResult` + `format_stop_reason_for_user()`
|
||||
11. **释放租约** — `finally` 中 `release_lease()`
|
||||
11. **释放租约** — `finally` 中**先** `release_lease()`,**再**关浏览器;进度 `emit` 不得用裸 `except Exception` 吞掉 `JobStopped`
|
||||
|
||||
## 开发新 skill 时如何复制
|
||||
|
||||
@@ -62,7 +62,7 @@ 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
|
||||
7. 在 `task_service.py` 中接入真实 DB、RpaVideoSession
|
||||
8. 在新技能根目录补齐 `scripts/main.py` CLI;`SKILL.md` 与根 `README.md` 按模板主目录规则编写,**不要**复制本 README
|
||||
|
||||
## Copy map(复制到新技能 `scripts/`)
|
||||
@@ -116,7 +116,6 @@ real_browser_rpa/
|
||||
- `_parse_item_locator()` 业务字段解析
|
||||
- 用户可见中文文案
|
||||
- 数据库写入逻辑
|
||||
- 任务日志逻辑
|
||||
- 录屏/截图业务标题
|
||||
|
||||
### 禁止照抄的
|
||||
@@ -151,7 +150,7 @@ real_browser_rpa/
|
||||
- **不自动破解验证码** — 滑块/短信只检测 + 等待人工完成
|
||||
- **不对抗平台风控** — 频控、账号风险、封禁或权限不足时停止并交由人工处理
|
||||
- **日志脱敏** — 不输出完整手机号/账号
|
||||
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` 释放
|
||||
- **租约释放** — `ensure-web` / `pick-web --lease` 后必须在 `finally` **先**释放租约再关浏览器;任务中心中断靠 `JobStopped` 进 `finally`,禁止吞掉停止信号
|
||||
- **失败留痕** — 真实 skill 应在关键失败点截图(示例中已留注释位)
|
||||
|
||||
## 常见坑
|
||||
@@ -175,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、视频留痕 |
|
||||
|
||||
## 依赖说明
|
||||
|
||||
|
||||
@@ -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 (
|
||||
@@ -236,17 +237,20 @@ async def _find_search_button(page):
|
||||
|
||||
|
||||
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:
|
||||
|
||||
@@ -74,7 +74,6 @@ async def run_keyword_search_task(
|
||||
|
||||
真实 skill 中还应:
|
||||
- 写 SQLite 入库(items 表)
|
||||
- 写 scrape_tasks / task_logs 任务日志
|
||||
- 使用 RpaVideoSession 录制步骤留痕
|
||||
- 失败时 capture_failure 截图
|
||||
"""
|
||||
|
||||
@@ -5,24 +5,24 @@
|
||||
## 状态
|
||||
|
||||
- **技术路线已真实验证**: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)
|
||||
2. [`../../development/RPA_DESKTOP.md`](../../development/RPA_DESKTOP.md)(含 Name/Value、最大化激活、同名叶子)
|
||||
3. 本 README 的“原始拾取到生产定位器”
|
||||
|
||||
## 成功案例验证了什么
|
||||
|
||||
```text
|
||||
枚举目标顶层窗口
|
||||
→ 按进程/窗口类/标题确认作用域
|
||||
→ 从 HWND 获取 UIA root
|
||||
→ 在父容器中定位元素
|
||||
→ 激活窗口并真实点击/输入
|
||||
→ 激活并最大化主窗(勿无条件 SW_RESTORE)
|
||||
→ 从 HWND 获取 UIA root / 业务子 pane
|
||||
→ 在父容器中定位(Name 与 Value 分开看)
|
||||
→ 真实点击并校验后置状态
|
||||
→ 等待子窗口或业务结果
|
||||
→ 处理独立/嵌套弹窗
|
||||
→ probe-ui 诊断
|
||||
@@ -30,6 +30,8 @@
|
||||
|
||||
来源案例还提供 `dry-run / assisted / auto` 三档、频率控制和业务记忆。本 example 只提炼 UIA 元素工程;账号、风控和数据库仍由具体业务技能实现。
|
||||
|
||||
本目录 `probe()` 是**最小探针**(主窗 + 通讯录入口,可选尝试新的客户行),**不等于**生产技能完整 probe。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
@@ -51,25 +53,39 @@ real_desktop_rpa/
|
||||
|
||||
## 原始拾取到生产定位器
|
||||
|
||||
来源案例的影刀 `selectorsV2` 导出通常包含:
|
||||
### 搜索框(添加客户窗)
|
||||
|
||||
```text
|
||||
Window(class=SearchExternalsWnd,title=添加客户)
|
||||
→ 多层 Layout/index
|
||||
→ search_layout
|
||||
→ searchedit
|
||||
原始: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、标题。
|
||||
2. 保留最小业务父容器:如 `search_layout`。
|
||||
3. 使用叶子元素稳定属性:如 `Name=searchedit`。
|
||||
4. 删除只反映当前布局的深层 `index`。
|
||||
1. 保留稳定顶层窗口:进程、Window Class、标题;需要时加上业务子 pane。
|
||||
2. 保留最小业务父容器。
|
||||
3. 叶子用稳定 Name **和/或** Value / 子控件 Value。
|
||||
4. 删除只反映当前布局的深层 index;index 仅作已验证兜底并写清含义。
|
||||
5. 验证父容器内候选数量为 `1`。
|
||||
6. 重启应用后再次验证。
|
||||
|
||||
不要把完整 XML 路径或 `item[0]` 原样变成生产定位器。
|
||||
不要把完整 XML 路径或未校验的 `item[0]` 原样变成生产定位器。
|
||||
|
||||
## Element map
|
||||
|
||||
@@ -79,8 +95,9 @@ Window(class=SearchExternalsWnd,title=添加客户)
|
||||
|
||||
- 窗口不唯一时失败,不取 `hwnds[0]`。
|
||||
- 控件不唯一时失败,不取第一个匹配。
|
||||
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器。
|
||||
- 行顺序不是身份;用业务文案或稳定属性选择结果行。
|
||||
- `item`、`status_layout`、`wbtn_container` 等通用 Name 必须放进稳定父容器,并用文案校验。
|
||||
- 行顺序不是身份;用业务文案(Value)选择结果行。
|
||||
- 主窗依赖完整侧栏时先 maximize;`activate(maximize=False)` 不得冲掉已最大化状态。
|
||||
- 只有主定位成功、窗口前台且矩形有效时,才允许从 UIA BoundingRectangle 降级坐标点击。
|
||||
|
||||
## 三种实施模式
|
||||
@@ -97,8 +114,8 @@ Window(class=SearchExternalsWnd,title=添加客户)
|
||||
|
||||
| 来源 | 目标 | 说明 |
|
||||
|---|---|---|
|
||||
| `scripts/service/desktop_session.py` | `scripts/service/desktop_session.py` | 顶层窗口枚举、唯一性、激活 |
|
||||
| `scripts/service/uia_locator.py` | `scripts/service/uia_locator.py` | 作用域内唯一定位、点击、输入 |
|
||||
| `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` | 真实拾取后填写 |
|
||||
@@ -118,14 +135,14 @@ Window(class=SearchExternalsWnd,title=添加客户)
|
||||
## 禁止照抄
|
||||
|
||||
- `WXWork`、`WeWorkWindow`、`SearchExternalsWnd`
|
||||
- `org_op_new`、`searchedit`、`wbtn_container`
|
||||
- 企业微信列表第 0/1 行含义
|
||||
- `org_op_new`、`searchedit`、`wbtn_container`、`main_chattext`
|
||||
- 企业微信列表第 0/1/2 行含义或固定 index
|
||||
- 来源案例的本机路径、账号和业务数据
|
||||
- 任何未经目标软件真实拾取验证的 fallback
|
||||
|
||||
## 验收
|
||||
|
||||
- `probe` 能明确报告依赖、窗口和关键元素
|
||||
- `probe` 能明确报告依赖、窗口和关键元素(生产技能覆盖关键跳转节点)
|
||||
- 每个关键元素在目标作用域内唯一
|
||||
- 关闭重开软件后仍能定位
|
||||
- 多窗口时不会误选
|
||||
|
||||
@@ -2,6 +2,10 @@ 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: 企业微信主窗口
|
||||
@@ -11,7 +15,7 @@ elements:
|
||||
window_class: WeWorkWindow
|
||||
title: 企业微信
|
||||
expected_count: 1
|
||||
postcondition: UIA root 可读取
|
||||
postcondition: UIA root 可读取;业务需要时已最大化
|
||||
replace_in_new_skill: true
|
||||
|
||||
- logical_name: contacts_entry
|
||||
@@ -21,8 +25,57 @@ elements:
|
||||
primary_locator:
|
||||
name: org_op_new
|
||||
control_type: TreeItem
|
||||
value_hint: 通讯录
|
||||
expected_count: 1
|
||||
postcondition: 通讯录业务面板出现
|
||||
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
|
||||
|
||||
@@ -10,3 +10,17 @@
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import ctypes
|
||||
import ntpath
|
||||
import time
|
||||
from ctypes import wintypes
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
@@ -31,6 +32,7 @@ class WindowCandidate:
|
||||
user32 = ctypes.windll.user32
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
SW_RESTORE = 9
|
||||
SW_MAXIMIZE = 3
|
||||
PROCESS_QUERY_LIMITED_INFORMATION = 0x1000
|
||||
|
||||
|
||||
@@ -105,7 +107,23 @@ def resolve_unique_window(
|
||||
return matched[0]
|
||||
|
||||
|
||||
def activate_window(window: WindowCandidate) -> None:
|
||||
user32.ShowWindow(window.hwnd, SW_RESTORE)
|
||||
if not user32.SetForegroundWindow(window.hwnd):
|
||||
raise DesktopRpaError("WINDOW_ACTIVATE_FAILED", f"无法激活 hwnd={window.hwnd}")
|
||||
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)
|
||||
|
||||
@@ -22,6 +22,15 @@ def root_from_window(window: WindowCandidate):
|
||||
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
|
||||
@@ -36,9 +45,21 @@ def _walk(root: Any, *, depth: int = 0, max_depth: int = 25) -> Iterator[Any]:
|
||||
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:
|
||||
@@ -52,6 +73,9 @@ def _matches(control: Any, spec: ElementSpec) -> bool:
|
||||
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
|
||||
|
||||
|
||||
@@ -64,7 +88,9 @@ def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
|
||||
)
|
||||
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)]
|
||||
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:
|
||||
@@ -75,8 +101,13 @@ def locate_unique(root: Any, spec: ElementSpec, *, max_depth: int = 25):
|
||||
return matched[0]
|
||||
|
||||
|
||||
def click_control(control: Any, *, window: WindowCandidate) -> None:
|
||||
activate_window(window)
|
||||
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", "目标元素不可见或边界矩形无效")
|
||||
@@ -89,7 +120,7 @@ def click_control(control: Any, *, window: WindowCandidate) -> None:
|
||||
|
||||
def type_text(control: Any, text: str, *, window: WindowCandidate) -> None:
|
||||
auto = require_uiautomation()
|
||||
click_control(control, window=window)
|
||||
click_control(control, window=window, maximize=False)
|
||||
auto.SendKeys("{Ctrl}a", waitTime=0.05)
|
||||
auto.SendKeys("{Delete}", waitTime=0.05)
|
||||
for char in text:
|
||||
|
||||
@@ -5,28 +5,49 @@ from __future__ import annotations
|
||||
from dataclasses import asdict
|
||||
from typing import Any
|
||||
|
||||
from service.desktop_session import resolve_unique_window
|
||||
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, SEARCH_INPUT
|
||||
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)
|
||||
return {
|
||||
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)
|
||||
|
||||
@@ -18,10 +18,16 @@ class WindowSpec:
|
||||
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
|
||||
|
||||
|
||||
@@ -37,6 +43,19 @@ CONTACTS_ENTRY = ElementSpec(
|
||||
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",
|
||||
|
||||
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
@@ -11,9 +12,11 @@ sys.path.insert(0, str(ROOT / "scripts"))
|
||||
from service.desktop_session import ( # noqa: E402
|
||||
DesktopRpaError,
|
||||
WindowCandidate,
|
||||
activate_window,
|
||||
resolve_unique_window,
|
||||
)
|
||||
from util.selectors import MAIN_WINDOW # noqa: E402
|
||||
from service import uia_locator # noqa: E402
|
||||
from util.selectors import ElementSpec, MAIN_WINDOW # noqa: E402
|
||||
|
||||
|
||||
class SelectorContractTest(unittest.TestCase):
|
||||
@@ -39,6 +42,58 @@ class SelectorContractTest(unittest.TestCase):
|
||||
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()
|
||||
|
||||
@@ -55,7 +55,7 @@ simulator_browser_rpa/
|
||||
| `browser_session.py` | async 系统 Chrome/Edge + launch args;**不在此 goto** |
|
||||
| `account_client.py` | **唯一** account-manager subprocess 封装(`pick_web_account`→ensure-web / `release_lease`) |
|
||||
| `simulator_playwright.py` | 门户轮询、登录、批量表单、PIN、解析 `batch_id`、截图 |
|
||||
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally release_lease` |
|
||||
| `adapter/simulator_rpa.py` | **薄** adapter:pick 账号、lease、委托 RPA、`finally` 先 `release_lease` 再关浏览器 |
|
||||
| `adapter/mock.py` | 不启浏览器,供 unit/mock/CI |
|
||||
| `task_service.py` | async 校验输入 → 选 adapter → `await submit_batch` |
|
||||
| `sandbox/demo_app.html` | 可控 DOM;含可选门户门闩 + 原批量提交流程 |
|
||||
|
||||
@@ -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 可用于跳过确认
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
343
references/CELL_PRESENTATION.md
Normal file
343
references/CELL_PRESENTATION.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 数据管理单元格呈现(Cell Presentation)契约(v1.0)
|
||||
|
||||
> **状态:正式契约 v1.0(`cells[]` 文档 `schemaVersion` 1)+ 加法 Grid Presentation。**
|
||||
> **目标:** 冻结「技能声明语义呈现 → 宿主映射主题样式」的通用能力:某一格、整行、整列、若干行×若干列,供任意业务场景复用。
|
||||
> **非目标:** 超时/SLA 专用字段;技能下发任意 CSS / 任意色值;宿主实现业务规则引擎。
|
||||
> **配套 Schema:** [`../assets/schemas/skill-cell-presentation.schema.json`](../assets/schemas/skill-cell-presentation.schema.json)(格子)、[`../assets/schemas/skill-grid-presentation.schema.json`](../assets/schemas/skill-grid-presentation.schema.json)(行/列/区域)。
|
||||
|
||||
相关正交轴见 [`ACTIONS.md`](ACTIONS.md)、[`POLICY.md`](POLICY.md)、[`SCHEMA.md`](SCHEMA.md)。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话
|
||||
|
||||
技能可以声明:**哪张业务表**上,**哪些行 × 哪些列**需要特殊标色/强调(语义 `tone` + 可选说明)。
|
||||
|
||||
| 想涂什么 | 怎么声明 |
|
||||
|----------|----------|
|
||||
| 某一格 | 一行 × 一列(v1 `cells[]` 或一条格子规则) |
|
||||
| 整行 | 若干行 × 该轴全部列 |
|
||||
| 整列 | 该轴全部行 × 若干列 |
|
||||
| 区域 | 若干行 × 若干列 |
|
||||
|
||||
用途不限:时效超时未填、库存预警、状态异常、必填缺失、审核驳回等。
|
||||
**不是**「再加一列超时文字」;强调落在**真正要关注的业务格子或行**上。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 能力档位
|
||||
|
||||
| 档位 | 技能行为 | 宿主行为 |
|
||||
|------|----------|----------|
|
||||
| **P0(默认)** | 不写呈现数据 / 未启用 | 数据管理表格与今日一致,无特殊标色 |
|
||||
| **P1** | 按规则写入呈现文档或推荐旁路表 | 渲染对应格子 / 行 / 列 / 区域的语义样式;可选汇总 `alert: true` 做进页提醒 |
|
||||
|
||||
多数技能保持 **P0**。需要视觉强调时再启用。模板 `actions.json` **默认不启用**。
|
||||
|
||||
---
|
||||
|
||||
## 0.2 宿主能力
|
||||
|
||||
| 能力 | 旁路表 | 宿主 |
|
||||
|------|--------|------|
|
||||
| 格子着色 | `_jiangchang_cell_presentation` | 既有宿主即可 |
|
||||
| 整行 / 整列 / 区域 | `_jiangchang_grid_presentation` | 需已读取该表(匠厂数据管理已支持)。**旧宿主忽略该表**,格子着色仍有效 |
|
||||
|
||||
同一开关 `cellPresentation.enabled` 同时打开两张旁路表。未建表 = 该通道为空,宿主忽略。
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责边界
|
||||
|
||||
| 角色 | 负责 | 不负责 |
|
||||
|------|------|--------|
|
||||
| **技能** | 按**当前业务数据**算出哪些格子/行/列要强调;在数据变更后**重算**并覆盖旧结果 | 不写宿主 CSS;不把强调文案污染进业务真值列冒充「变红」 |
|
||||
| **宿主** | 读取呈现结果,按闭集 `tone` 映射主题样式;按几何涂表格;可选 Toast/提醒汇总 | 不理解「提前 3 天交单」等业务规则;不为单个技能写死样式 |
|
||||
|
||||
**派生状态(重要):** 呈现是计算结果,不是粘性开关。
|
||||
用户补填数据 / 同步刷新后技能重算 → 该项消失 → **样式与提醒自然关闭**。不要单独维护「已关闭提醒」状态机(除非产品另做「忽略此条」能力,本契约不冻结)。
|
||||
|
||||
---
|
||||
|
||||
## 2. 格子文档形状(冻,`schemaVersion` 1)
|
||||
|
||||
技能产出一份 **Cell Presentation Document**(可落库、可 CLI 打印,形状相同),须符合格子 Schema。v1 **只描述某一格**;整行/整列用 §2.1。
|
||||
|
||||
```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.0 `cells[]` 项
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `rowId` | 是 | `rowKey` 列值的字符串形式 |
|
||||
| `column` | 是 | 要强调的**业务列**(snake_case),须真实存在 |
|
||||
| `tone` | 是 | 见 §3 |
|
||||
| `message` | 否 | 短说明(≤500);悬停 / 无障碍 |
|
||||
| `alert` | 否 | 默认 `false`;`true` 可计入进页/同步提醒汇总 |
|
||||
|
||||
同一 `(rowId, column)` 多条:宿主可**后者覆盖前者**。
|
||||
`column` 对用户不可见(policy 打码 / 列隐藏)时:宿主可仍应用样式,或跳过——**推荐跳过不可见列的强调**,避免泄露「有问题」的存在感(技能仍可写,宿主按可见性过滤)。
|
||||
|
||||
### 2.1 Grid 文档形状(加法,独立 Schema)
|
||||
|
||||
整行 / 整列 / 区域用 **Grid Presentation Document**(`rules[]`)。与 `cells[]` **并存**,不替代、不升 `cells` 的 `schemaVersion`。
|
||||
|
||||
统一几何:**`rows` × `columns`**。某一侧为 `"all"`(或省略,落库时写成 JSON `"all"`)= 该轴全部。禁止两侧都是全部(见 §4.2)。
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"generatedAt": "2026-08-10T06:00:00Z",
|
||||
"rules": [
|
||||
{
|
||||
"ruleKey": "qty-low-rows",
|
||||
"rows": ["42", "99"],
|
||||
"columns": "all",
|
||||
"tone": "danger",
|
||||
"message": "库存低于安全线",
|
||||
"alert": true
|
||||
},
|
||||
{
|
||||
"ruleKey": "status-column",
|
||||
"rows": "all",
|
||||
"columns": ["status"],
|
||||
"tone": "warning",
|
||||
"message": "待审核"
|
||||
},
|
||||
{
|
||||
"ruleKey": "overdue-print-region",
|
||||
"rows": ["7", "8"],
|
||||
"columns": ["print_done_at", "ship_done_at"],
|
||||
"tone": "danger",
|
||||
"message": "环节到期未填",
|
||||
"alert": true
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `schemaVersion` | 是 | 固定 `1` |
|
||||
| `skill` / `table` / `rowKey` | 是 | 同格子文档;`rowKey` 用于匹配 `rows` 里的 id |
|
||||
| `rules` | 是 | 数组;**空数组 = 该表清除全部 grid 规则** |
|
||||
|
||||
#### `rules[]` 项
|
||||
|
||||
| 字段 | 必填 | 说明 |
|
||||
|------|------|------|
|
||||
| `ruleKey` | 是 | 本表内唯一(建议稳定业务键,便于重算覆盖) |
|
||||
| `rows` | 是 | `"all"` 或行 id 字符串数组(对应 `rowKey` 列值) |
|
||||
| `columns` | 是 | `"all"` 或业务列名数组 |
|
||||
| `tone` | 是 | 见 §3 |
|
||||
| `message` | 否 | ≤500 |
|
||||
| `alert` | 否 | 默认 `false`;按**规则条数**计提醒,不按命中格子数 |
|
||||
|
||||
| `rows` × `columns` | 宿主 scope |
|
||||
|--------------------|-----------|
|
||||
| 1 行 × 1 列 | `cell` |
|
||||
| 若干行 × 若干列(非单格) | `region` |
|
||||
| 若干行 × `"all"` | `row`(整行) |
|
||||
| `"all"` × 若干列 | `column`(整列) |
|
||||
| `"all"` × `"all"` | **禁止**,宿主丢弃该规则 |
|
||||
|
||||
完整 JSON 例见 [`../assets/examples/skill-grid-presentation.example.json`](../assets/examples/skill-grid-presentation.example.json)。
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
);
|
||||
```
|
||||
|
||||
### 4.1 整行 / 整列 / 区域(`_jiangchang_grid_presentation`)
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS _jiangchang_grid_presentation (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
table_name TEXT NOT NULL,
|
||||
rule_key TEXT NOT NULL,
|
||||
row_key TEXT NOT NULL DEFAULT '',
|
||||
rows_json TEXT NOT NULL,
|
||||
columns_json 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, rule_key)
|
||||
);
|
||||
```
|
||||
|
||||
`rows_json` / `columns_json` 存 JSON:字符串 `"all"`,或 id / 列名数组(如 `["42","99"]`)。
|
||||
`row_key`:有限行集合时必填(与文档 `rowKey` 相同,如 `id`);整列(`rows_json = "all"`)可空字符串。
|
||||
|
||||
两张表都列入技能 `INTERNAL_METADATA_TABLES`(见 `scripts/db/display_metadata.py`),**不得**作为业务表出现在数据管理侧栏。模板 **不**在 `init_db()` 里默认建这两张表;技能用到时再 `CREATE`。
|
||||
|
||||
### 4.2 冲突与特异性
|
||||
|
||||
同一格被多条规则命中时,宿主按 **格子 > 区域 > 行 > 列** 取胜。同级后者覆盖前者。
|
||||
|
||||
宿主先加载 grid 规则,再加载 v1 `cells[]`(同级时 **v1 格子仍赢**),因此旧技能只写 `cells[]` 的行为不变。
|
||||
|
||||
**禁止 `all` × `all`**(会把整张表涂满)。技能不要写;宿主遇到则丢弃该规则。
|
||||
|
||||
### 4.3 表格 vs 详情
|
||||
|
||||
| 表面 | 整行(scope=`row`) | 格子 / 列 / 区域 |
|
||||
|------|-------------------|------------------|
|
||||
| 数据管理表格 | 给整行(`<tr>`)上色 | 给对应单元格上色 |
|
||||
| 行详情 | **不**把行色铺到每个字段 | 对应字段可保留 tone |
|
||||
|
||||
整行强调的是「这一行在表里扎眼」,不是「打开详情后每个值都变色」。
|
||||
|
||||
约定:
|
||||
|
||||
- 重算某表时:**先删该 `table_name` 在两张旁路表中的全部行,再插入新结果**(或等价事务替换),保证填完后旧 `danger` 不会残留。只维护其中一张时,至少清掉自己负责的那张,并删掉已迁走的旧行(例如从格子改成整行后,删掉对应 `_jiangchang_cell_presentation`)。
|
||||
- 宿主打开数据管理某表时:若该技能启用了呈现(见 §5)且存在旁路表,则按当前页行的 `rowKey` 值匹配并着色。
|
||||
- 也允许技能用 CLI 打印整份 Document 供调试;**运行时以库内旁路表为准**(除非后续宿主另定拉取入口)。
|
||||
|
||||
未创建该旁路表 = 该通道 P0,宿主忽略。
|
||||
|
||||
---
|
||||
|
||||
## 5. 在 `actions.json` 中启用(可选,推荐)
|
||||
|
||||
与 `policy` 类似,顶层可选。**一份开关同时启用格子与 grid:**
|
||||
|
||||
```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` 的**规则条数**(grid 按规则计,不按命中格子数);**0 则不打扰**。
|
||||
提醒关闭 = 重算后没有对应 cell/rule,而不是用户点「我知道了」写死关闭(除非另做忽略列表,本契约不冻)。
|
||||
|
||||
---
|
||||
|
||||
## 7. 与其它契约的关系
|
||||
|
||||
| 契约 | 关系 |
|
||||
|------|------|
|
||||
| **SCHEMA / `_jiangchang_columns`** | 管列是否可见、类型、中文名;**不管**条件标色。呈现旁路表须进内部隐藏表集合 |
|
||||
| **POLICY Snapshot** | 管谁能看哪些 Action/行列;呈现仍建议按可见列过滤 |
|
||||
| **hostEffects / steps** | 管 Action 跑完后是否 `reloadTable`;重载后宿主应重新读呈现 |
|
||||
| **业务列真值** | 保持干净;不要把 `【超时】` 写进业务字段冒充变色 |
|
||||
|
||||
---
|
||||
|
||||
## 8. 反模式
|
||||
|
||||
- 为标色新增「超时环节」「时效状态」等**业务展示冗余列**当作终态方案
|
||||
- 技能下发 CSS / 内联颜色
|
||||
- 只在首次超时时打标、填完不重算导致红字残留
|
||||
- 宿主写死某个技能 slug 的特殊染色逻辑
|
||||
- 把 Cell Presentation 做成仅服务 SLA 的专用协议
|
||||
- 写 `rows: "all"` 且 `columns: "all"` 企图整表变色
|
||||
- 假定打开行详情后,整行色会铺到每一个字段
|
||||
- 把某一业务技能的检查逻辑(例如「检查列 → 整行」)复制进模板当通用运行时
|
||||
|
||||
---
|
||||
|
||||
## 9. 示例场景(说明用途不限)
|
||||
|
||||
| 场景 | 几何 | tone | message 例 |
|
||||
|------|------|------|------------|
|
||||
| 打单超时未填 | 格子 `print_done_at` | `danger` | 打单已超 1 天未填 |
|
||||
| 库存低于安全线 | **整行**(该行 × `all`) | `warning` | 低于安全库存 |
|
||||
| 审核驳回 | 格子或整列 `status` | `danger` | 已驳回,请修改 |
|
||||
| 选填备注缺失但不紧急 | 格子 `notes` | `muted` | 建议补充备注 |
|
||||
| 两个日期列同时逾期 | 区域:若干行 × `print_done_at`+`ship_done_at` | `danger` | 环节到期未填 |
|
||||
|
||||
格子 JSON 例见 [`../assets/examples/skill-cell-presentation.example.json`](../assets/examples/skill-cell-presentation.example.json)。
|
||||
Grid JSON 例见 [`../assets/examples/skill-grid-presentation.example.json`](../assets/examples/skill-grid-presentation.example.json)。
|
||||
@@ -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)。快照「重新生成」须有 Action id **`render-html`**;壳子页与页面调用 `jiangchang.call` 见 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
383
references/POLICY.md
Normal 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;写路径技能自检
|
||||
@@ -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`。
|
||||
|
||||
@@ -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))
|
||||
- 保证元数据与真实数据库结构一致
|
||||
- 保证元数据初始化幂等
|
||||
|
||||
@@ -20,13 +25,18 @@
|
||||
|
||||
- 读取 `_jiangchang_*` 元数据
|
||||
- 读取 SQLite `PRAGMA table_info`
|
||||
- **数据管理侧栏/表列表**:当库中存在 `_jiangchang_tables` 时,**仅展示**在该表注册且 `visible=1` 的业务表;孤儿物理表(有表、无注册元数据)**不得**出现在侧栏,直接访问 schema API 应返回「表不存在」。极老库无 `_jiangchang_tables` 时,宿主可保留物理表枚举 fallback(兼容 `_schema_meta` 时代)
|
||||
- 根据 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`);不猜测业务规则;整行色只涂表格行,不铺到详情字段
|
||||
|
||||
## 初始化入口
|
||||
|
||||
@@ -35,7 +45,8 @@
|
||||
SQLite **没有**可靠的 `COMMENT ON TABLE/COLUMN`;SQL 文件里的 `-- 注释` 也不会成为可查询结构。
|
||||
因此中文展示名称必须写入元数据表;字段顺序必须写在 `CREATE TABLE` 的列定义顺序中(`PRAGMA table_info` 的 `cid`)。
|
||||
|
||||
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
|
||||
> **禁止**新技能使用宿主的旧版 `_schema_meta` 表。仅 `_jiangchang_tables` / `_jiangchang_columns` 为正式契约。
|
||||
> 可选呈现旁路表 `_jiangchang_cell_presentation` / `_jiangchang_grid_presentation` **不是**展示元数据,但须列入 `INTERNAL_METADATA_TABLES`(侧栏不可见)。见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md)。
|
||||
|
||||
## 技能名称(SKILL.md)
|
||||
|
||||
@@ -61,7 +72,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 +89,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 +137,14 @@ metadata:
|
||||
}
|
||||
```
|
||||
|
||||
### 同步数据 vs 导出当前数据表 vs 特殊业务报告
|
||||
### 同步数据 vs 导出当前数据表 vs 特殊业务报告 vs HTML 页面
|
||||
|
||||
| 能力 | 语义 | 负责方 |
|
||||
|------|------|--------|
|
||||
| **同步数据** | 外部来源 → 技能本地数据库 | 技能 Action / domain service |
|
||||
| **导出当前数据表** | 本地库 → CSV/Excel 等 | 数据管理页**宿主统一导出** |
|
||||
| **生成特殊业务报告** | 多表合并、特殊排版或附加计算 | 仅当系统导出无法满足时,才提供独立 `export-*` |
|
||||
| **生成特殊业务报告(文件)** | 多表合并、特殊排版或附加计算的文件 | 仅当系统导出无法满足时,才提供独立 `export-*`;落 `output/` |
|
||||
| **HTML 页面报告** | 侧栏可预览的整页 HTML | 壳子页 `assets/html/pages/`;快照写入 `html/` + Action id **`render-html`**(见 [`ACTIONS.md`](ACTIONS.md)) |
|
||||
|
||||
同步型 Action 默认只写库、不生成导出文件。兼容旧 export 命令需要刷新时,必须调用同一 sync 内核。可复用测试范式见 `tests/samples/test_sync_contract.py.sample`。
|
||||
|
||||
@@ -127,10 +158,142 @@ 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` 必须是该表**真实物理列名**(存在于 SQLite 表);不存在则宿主忽略该段或技能校验报错
|
||||
- **可以**引用 `visible=0` 的列(含隐藏的主键 `id`)。`visible=0` 只表示界面不展示 / 不进默认 SELECT·筛选·搜索;**不表示不能 ORDER BY**
|
||||
- 敏感列(宿主标为 sensitive)不得用于排序;技能侧勿把密钥类字段写进 `default_sort_json`
|
||||
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
|
||||
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
|
||||
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
|
||||
|
||||
与 `visible` 的分工(勿再踩坑):
|
||||
|
||||
| 能力 | `visible=0` 是否仍可用 |
|
||||
|------|------------------------|
|
||||
| 表格列展示 / 「列」面板 | 否(硬隐藏) |
|
||||
| 默认 SELECT、列筛选、顶栏搜索 | 否 |
|
||||
| **`default_sort_json` / ORDER BY** | **是**(非敏感列) |
|
||||
| 行主键定位(`$row.$pk` 等) | 是(主键即使隐藏仍应可排序、可定位;宿主查询层保留) |
|
||||
|
||||
示例(编号硬隐藏,但仍按编号排序——合法):
|
||||
|
||||
```python
|
||||
upsert_table_metadata(
|
||||
cursor,
|
||||
table_name="period_rule_overrides",
|
||||
display_name="本月默认金额",
|
||||
readonly=1,
|
||||
default_sort_json='[{"column":"period","direction":"desc"},{"column":"id","direction":"asc"}]',
|
||||
)
|
||||
# id 列元数据可见性:
|
||||
# upsert_column_metadata(..., column_name="id", visible=0, ...)
|
||||
```
|
||||
|
||||
示例(消息时间线):
|
||||
|
||||
```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 +301,9 @@ metadata:
|
||||
- 使用 `_schema_meta` 作为新的正式契约
|
||||
- 只写英文物理名而不写中文 `display_name`
|
||||
- 让元数据引用不存在的表
|
||||
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
|
||||
- 误以为「列 `visible=0` 就不能写进 `default_sort_json`」——排序与展示已拆开;隐藏编号/主键仍可排序(须列真实存在且非敏感)
|
||||
- 把不存在的列名写进 `default_sort_json`(技能校验应报错;宿主也会忽略无效段)
|
||||
|
||||
### `_jiangchang_columns` 字段级标准
|
||||
|
||||
@@ -145,17 +311,176 @@ metadata:
|
||||
|------|------|------|
|
||||
| `table_name` | 是 | 所属物理表 |
|
||||
| `column_name` | 是 | 真实英文字段名 |
|
||||
| `display_name` | 是 | 用户可见的中文字段名 |
|
||||
| `description` | 否 | 字段说明 |
|
||||
| `display_name` | 是 | 用户可见的中文字段名(表头与表单标签);保持简短 |
|
||||
| `description` | 否 | 字段说明(给人看的短文案);**宿主在新增/编辑表单标签下方展示**(见下节);不要塞进 `display_name` |
|
||||
| `grid_help` | 否 | 数据管理表头「?」;空/未写则不出。与表单 `description` 独立,禁止互拷 |
|
||||
| `display_order` | 否 | 兼容字段,默认保持 `NULL` |
|
||||
| `visible` | 是 | 是否在数据管理表格中展示 |
|
||||
| `searchable` | 是 | 是否参与搜索 |
|
||||
| `visible` | 是 | 是否进入数据管理 schema /「列」面板(`0` = **硬隐藏**,用户无法勾回;默认不展示但可勾回请用表级 `default_visible_columns_json`) |
|
||||
| `searchable` | 是 | 是否参与**列筛选**与**顶栏全局搜索**(`0` = 两者均不参与);详见下节「数据管理:列筛选与全局搜索」 |
|
||||
| `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 配置入口混用。
|
||||
|
||||
#### 数据管理:列筛选与全局搜索(宿主支持)
|
||||
|
||||
数据管理表格提供两类与 `searchable` 相关的检索能力,技能通过 `_jiangchang_columns.searchable` 声明是否参与:
|
||||
|
||||
| 能力 | 用户入口 | `searchable = 1` 时 | `searchable = 0` 时 |
|
||||
|------|----------|---------------------|---------------------|
|
||||
| **列筛选** | 表格「筛选」→ 字段 / 操作符 / 值 | 出现在「字段」下拉,可按类型选值与操作符 | 不出现在列筛选字段列表 |
|
||||
| **全局搜索** | 顶栏搜索框 | 参与 OR 模糊匹配(宿主对参与列数有上限,如最多 8 列) | 不参与全局搜索 |
|
||||
|
||||
**存库 vs 展示(核心契约)**
|
||||
|
||||
- **列表**:有 `options_json` 时展示 **label**(如「启用」「停用」)
|
||||
- **筛选 / 查询 / 全局搜索**:比较与匹配 **value**(如 `0`/`1`、`active`、`toutiaohao`)
|
||||
- 宿主负责 label ↔ value 映射;**禁止**为迁就 UI 把存库改成中文枚举,也**禁止**在 `description` 里写「筛选请填 0/1」
|
||||
|
||||
**筛选栏控件 ≠ 新增/编辑表单(勿混用)**
|
||||
|
||||
列筛选「值」与表单使用同一套**类型语义**,但控件形态不同:筛选栏须与「字段」「操作符」同一行工具栏对齐(如统一 `h-9` 下拉),**枚举 / boolean / lookup 在筛选栏一律用下拉 Select**,不用新增/编辑表单里的开关或横向分段单选。表单侧仍按上表「新增/编辑控件」配置。
|
||||
|
||||
| 列类型 | 筛选「值」控件 | 常用操作符(宿主按类型裁剪) | 技能须配置 |
|
||||
|--------|----------------|------------------------------|------------|
|
||||
| 文本 / `url` / `email` | 文本框 | 等于 / 包含 / 开头 / 结尾 / 为空… | 通常 `searchable: 1` |
|
||||
| 数字 | 数字框 | 等于 / 大于 / 小于… | `display_type: number` 或 INT/REAL |
|
||||
| 枚举(`options_json` **数组**) | 下拉(展示 label) | 等于 / 不等于 / 为空 | **`options_json` 必填** + `searchable: 1` |
|
||||
| `boolean` | 下拉(有 `options_json` 用其 label;否则宿主默认可选「是/否」) | 等于 / 不等于 / 为空 | `display_type: boolean`;**推荐同时写 `options_json`** 指定中文 label |
|
||||
| `lookup` 单选 | 下拉 | 等于 / 不等于 / 为空 | `display_type: lookup` + lookup 对象 |
|
||||
| `lookup` + `multiple: true` | 列筛选 UI 演进中;**全局搜索**已支持 | 等于时按逗号分隔 id 的 token 匹配 | 多选存 `TEXT` 逗号分隔 id |
|
||||
| `date` / `datetime_unix_seconds` | 日期 / 日期时间 | 比较类 | 时间列通常 `searchable: 0` |
|
||||
| 敏感列 | **不可筛** | — | 宿主强制排除 |
|
||||
|
||||
**全局搜索扩展(用户可搜中文)**
|
||||
|
||||
宿主在 `searchable = 1` 的列上除原文 `LIKE` 外,还应:
|
||||
|
||||
- 有 `options_json` 枚举:**label 或 value** 均可命中
|
||||
- `boolean` 无 options:认「是/否」等常见别名
|
||||
- `lookup` 多选:按逗号分隔 id 的 **token 边界** 匹配;选项 label 亦可参与
|
||||
- `datetime_unix_seconds`:搜 `YYYY-MM-DD` 形态可匹配当天 Unix 秒区间
|
||||
|
||||
**技能配置建议**
|
||||
|
||||
推荐:
|
||||
|
||||
- 用户常筛的状态列(启用/停用、待生成/已生成、`status` 等)→ `searchable: 1` + `options_json`(value 与 DDL 存库一致)
|
||||
- `boolean` 业务列:`display_type: boolean` **且** `options_json` 写中文 label(示例见下)
|
||||
|
||||
禁止:
|
||||
|
||||
- 仅为筛选把存库 value 改成中文
|
||||
- 在文档或 `description` 要求用户记内部 magic number / 英文 code
|
||||
|
||||
示例(启用开关 + 中文筛选):
|
||||
|
||||
```python
|
||||
{
|
||||
"column_name": "enabled",
|
||||
"display_name": "是否启用",
|
||||
"visible": 1,
|
||||
"searchable": 1,
|
||||
"editable": 1,
|
||||
"display_type": "boolean",
|
||||
"options_json": json.dumps(
|
||||
[{"value": 0, "label": "停用"}, {"value": 1, "label": "启用"}],
|
||||
ensure_ascii=False,
|
||||
),
|
||||
}
|
||||
```
|
||||
|
||||
Action 参数表单**无**列筛选对称能力;筛选语义以本节为准,表单对照见 [`ACTIONS.md`](ACTIONS.md)「表单字段类型对照」。
|
||||
|
||||
## editable 与 createable 的关系
|
||||
|
||||
技能模板只维护 `editable` 这一份字段权限声明:
|
||||
@@ -260,15 +585,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 +613,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 +669,11 @@ CREATE TABLE demo_items (
|
||||
|
||||
## 枚举字段 options_json
|
||||
|
||||
`options_json` 为 JSON 数组,每项包含 `value` 与 `label`:
|
||||
`options_json` 支持两种形态:
|
||||
|
||||
### 1)静态枚举(数组)
|
||||
|
||||
每项包含 `value` 与 `label`:
|
||||
|
||||
```json
|
||||
[
|
||||
@@ -366,11 +685,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`
|
||||
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
|
||||
- 元数据不能引用不存在的表或字段
|
||||
- 新增字段或表时必须同步增加元数据
|
||||
@@ -414,30 +799,62 @@ seed_column_metadata_batch(
|
||||
|
||||
不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。
|
||||
|
||||
## 表增删改与退役(技能 schema 迁移)
|
||||
|
||||
技能拥有 SQLite 物理 schema;宿主只渲染**已注册**的元数据。增删改表时技能须在 `init_db()` 中做幂等迁移,并与 `_jiangchang_*` 同步。
|
||||
|
||||
| 操作 | 技能必须做 |
|
||||
|------|------------|
|
||||
| **新增**用户可见表 | `CREATE TABLE` + `upsert_table_metadata` + 列元数据种子 |
|
||||
| **修改**表/列 | 物理迁移(`ALTER` / 重建)+ 更新或 `prune_stale_column_metadata` |
|
||||
| **临时隐藏**(表仍保留) | 元数据 `visible=0`(表与元数据行都保留) |
|
||||
| **永久废弃**用户可见表 | `drop_deprecated_user_table(...)`(见下) |
|
||||
|
||||
### 永久废弃用户可见表
|
||||
|
||||
使用 `scripts/db/display_metadata.py` 的 **`drop_deprecated_user_table`**(幂等):
|
||||
|
||||
1. `DROP TRIGGER`(若有 `{table}_set_updated_at` 等)
|
||||
2. `DROP TABLE IF EXISTS`
|
||||
3. `DELETE` 对应 `_jiangchang_columns` / `_jiangchang_tables` 行
|
||||
|
||||
```python
|
||||
from db.display_metadata import drop_deprecated_user_table
|
||||
|
||||
def init_db() -> None:
|
||||
...
|
||||
drop_deprecated_user_table(cur, "task_logs")
|
||||
init_display_metadata(cur)
|
||||
```
|
||||
|
||||
**反模式(禁止):** 只删 `_jiangchang_*` 元数据、保留物理表。宿主虽可能不展示孤儿表,但库内残留、自检失败,且违背「元数据与物理结构一致」。
|
||||
|
||||
**新技能禁止**创建或写入 `task_logs` 一类通用任务审计表。过程 → `jiangchang.log` / 运行日志;进度/终态 → 任务中心;业务结果 → 业务表(见 [`WORK_ITEM_STATUS.md`](WORK_ITEM_STATUS.md)、[`../development/LOGGING.md`](../development/LOGGING.md))。
|
||||
|
||||
### 旧库迁移测试(废弃表时必做)
|
||||
|
||||
复制 `tests/samples/test_table_deprecation_migration.py.sample`,构造「旧库含废弃表 + 元数据 + 历史行」→ `init_db()` 后断言:
|
||||
|
||||
- `sqlite_master` 无该表
|
||||
- `_jiangchang_*` 无残留行
|
||||
- `iter_user_tables` 不含该表
|
||||
|
||||
卸载技能**不删**用户数据目录;重装会复用旧库,迁移必须在 `init_db()` 中完成(见 [`../development/DATA_PATHS.md`](../development/DATA_PATHS.md))。
|
||||
|
||||
## 中文命名质量
|
||||
|
||||
- 使用普通用户能理解的业务用语:`task_logs` → **任务日志**,`error_msg` → **错误信息**。
|
||||
- 使用普通用户能理解的业务用语:`error_msg` → **错误信息**,`created_at` → **创建时间**。
|
||||
- 禁止:`Task Logs`、`input编号`、`errormsg`、直接把物理名当 `display_name`。
|
||||
- 不宜向用户暴露的实现细节(如 `payload_json`、`selector`)应设 `visible = 0` 或给出可理解中文名。
|
||||
|
||||
## 元数据同步规则
|
||||
|
||||
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`。
|
||||
1. 新增用户可见业务表 → 同步 `_jiangchang_tables`(`visible=1` 方可在数据管理展示)。
|
||||
2. 新增用户可见字段 → 同步 `_jiangchang_columns`。
|
||||
3. 删除/重命名表或字段 → 同步清理或迁移元数据。
|
||||
3. 删除/重命名表或字段 → 物理迁移 + 同步清理元数据;**废弃表**用 `drop_deprecated_user_table`,禁止只删元数据留物理表。
|
||||
4. 初始化脚本必须幂等。
|
||||
5. 元数据中的表、字段必须在库中真实存在;可见项不得缺少中文 `display_name`。
|
||||
|
||||
## 不同业务的 `task_logs` 映射
|
||||
|
||||
| 业务场景 | task_type | target_id | input_id |
|
||||
|----------|-----------|-----------|----------|
|
||||
| 发布类 | publish | 账号 ID | 内容 ID |
|
||||
| 工资代发 | disburse | 付款账户 | 批次 ID |
|
||||
| 对账 | reconcile | 平台标识 | 对账批次 ID |
|
||||
| 发票验真 | verify | 税务地区 | 发票批次 ID |
|
||||
|
||||
业务特有字段优先放入 `result_summary`(JSON 字符串),避免随意加列;若确需新列,必须同步元数据并保持合理物理顺序。
|
||||
6. `init_db()` 后:`iter_user_tables` 中每个物理业务表都应有对应 `_jiangchang_tables.display_name`,或已被 `drop_deprecated_user_table` 物理删除。
|
||||
|
||||
## 开发自检
|
||||
|
||||
@@ -448,5 +865,5 @@ seed_column_metadata_batch(
|
||||
## 模板原则
|
||||
|
||||
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
|
||||
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。
|
||||
- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。
|
||||
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。
|
||||
|
||||
157
references/WORK_ITEM_STATUS.md
Normal file
157
references/WORK_ITEM_STATUS.md
Normal 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/任务中心边界 |
|
||||
220
release.ps1
220
release.ps1
@@ -7,6 +7,10 @@
|
||||
Encryption, ZIP packaging, and marketplace sync are done by CI
|
||||
(.github/workflows/release_skill.yaml → reusable-release-skill.yaml@main).
|
||||
|
||||
POLICY-RELEASE-001: before tagging, requires SKILL.md version and
|
||||
scripts/util/constants.py SKILL_VERSION to equal the next tag (without prefix).
|
||||
release.ps1 does not auto-edit those fields.
|
||||
|
||||
Requires: git, PowerShell 5+
|
||||
|
||||
After fetch, runs default tests against the current working tree (before auto-commit):
|
||||
@@ -17,7 +21,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Prefix = "v",
|
||||
[string]$Message = "正式发布",
|
||||
[string]$Message,
|
||||
[switch]$AutoCommit,
|
||||
[switch]$RequireClean,
|
||||
[string]$CommitMessage,
|
||||
@@ -28,6 +32,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 +46,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 +148,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) {
|
||||
@@ -110,6 +166,93 @@ function Get-NextTag {
|
||||
return "$TagPrefix$($latest.Major).$($latest.Minor).$([int]$latest.Patch + 1)"
|
||||
}
|
||||
|
||||
function Read-SkillMdVersion {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
$path = Join-Path $SkillRoot "SKILL.md"
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
return $null
|
||||
}
|
||||
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return $null
|
||||
}
|
||||
$m = [regex]::Match($text, '(?m)^version:\s*["'']?([0-9]+\.[0-9]+\.[0-9]+)["'']?\s*$')
|
||||
if (-not $m.Success) {
|
||||
return $null
|
||||
}
|
||||
return $m.Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
function Read-SkillConstantsVersion {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
$path = Join-Path $SkillRoot "scripts\util\constants.py"
|
||||
if (-not (Test-Path -LiteralPath $path)) {
|
||||
return $null
|
||||
}
|
||||
$text = Get-Content -LiteralPath $path -Raw -Encoding UTF8 -ErrorAction SilentlyContinue
|
||||
if ([string]::IsNullOrWhiteSpace($text)) {
|
||||
return $null
|
||||
}
|
||||
$m = [regex]::Match($text, '(?m)^SKILL_VERSION\s*=\s*["'']([0-9]+\.[0-9]+\.[0-9]+)["'']')
|
||||
if (-not $m.Success) {
|
||||
return $null
|
||||
}
|
||||
return $m.Groups[1].Value.Trim()
|
||||
}
|
||||
|
||||
# POLICY-RELEASE-001: marketplace latest (git tag) must equal package manifest versions.
|
||||
function Assert-SkillReleaseVersionAlignment {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$SkillRoot,
|
||||
[Parameter(Mandatory = $true)][string]$NextTag,
|
||||
[Parameter(Mandatory = $true)][string]$TagPrefix
|
||||
)
|
||||
|
||||
$skillMdPath = Join-Path $SkillRoot "SKILL.md"
|
||||
if (-not (Test-Path -LiteralPath $skillMdPath)) {
|
||||
return
|
||||
}
|
||||
|
||||
$expected = $NextTag
|
||||
if ($TagPrefix -and $expected.StartsWith($TagPrefix)) {
|
||||
$expected = $expected.Substring($TagPrefix.Length)
|
||||
}
|
||||
$expected = $expected.Trim()
|
||||
if ($expected -notmatch '^\d+\.\d+\.\d+$') {
|
||||
throw "Release check failed (POLICY-RELEASE-001): cannot derive semver from Next tag '$NextTag'."
|
||||
}
|
||||
|
||||
$mdVersion = Read-SkillMdVersion -SkillRoot $SkillRoot
|
||||
$constVersion = Read-SkillConstantsVersion -SkillRoot $SkillRoot
|
||||
|
||||
$problems = @()
|
||||
if ([string]::IsNullOrWhiteSpace($mdVersion)) {
|
||||
$problems += "SKILL.md missing top-level version: X.Y.Z"
|
||||
}
|
||||
elseif ($mdVersion -ne $expected) {
|
||||
$problems += "SKILL.md version=$mdVersion (want $expected)"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace($constVersion)) {
|
||||
$problems += "scripts/util/constants.py missing SKILL_VERSION = `"X.Y.Z`""
|
||||
}
|
||||
elseif ($constVersion -ne $expected) {
|
||||
$problems += "SKILL_VERSION=$constVersion (want $expected)"
|
||||
}
|
||||
|
||||
if ($problems.Count -gt 0) {
|
||||
throw (
|
||||
"Release aborted (POLICY-RELEASE-001): Next tag=$NextTag requires manifest version $expected, but " +
|
||||
($problems -join "; ") +
|
||||
". Bump SKILL.md version and scripts/util/constants.py SKILL_VERSION to $expected (and CHANGELOG), then re-run release.ps1. " +
|
||||
"release.ps1 does not auto-edit these fields."
|
||||
)
|
||||
}
|
||||
|
||||
Write-Host "Version alignment OK: tag=$NextTag == SKILL.md=$mdVersion == SKILL_VERSION=$constVersion" -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Assert-SkillReleasePackagingSources {
|
||||
param([Parameter(Mandatory = $true)][string]$SkillRoot)
|
||||
|
||||
@@ -181,7 +324,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 +343,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,20 +366,29 @@ 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
|
||||
}
|
||||
|
||||
$nextTag = Get-NextTag -TagPrefix $Prefix
|
||||
Write-Host "Next tag: $nextTag" -ForegroundColor Green
|
||||
Assert-SkillReleaseVersionAlignment -SkillRoot $skillRoot -NextTag $nextTag -TagPrefix $Prefix
|
||||
|
||||
$existing = @(Get-GitOutput -GitArgs @("tag", "--list", $nextTag))
|
||||
if ($existing.Length -gt 0) {
|
||||
throw "Tag already exists: $nextTag"
|
||||
}
|
||||
|
||||
Invoke-DefaultTests -SkillRoot $skillRoot -Skip:$SkipTests
|
||||
|
||||
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 +398,21 @@ 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`"")
|
||||
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
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,11 +9,18 @@ from __future__ import annotations
|
||||
from typing import Iterable
|
||||
|
||||
# 宿主会隐藏这些表,不作为业务表展示(见 jiangchang electron/data-management/types.ts)
|
||||
INTERNAL_METADATA_TABLES = frozenset({"_jiangchang_tables", "_jiangchang_columns"})
|
||||
# 呈现旁路表由技能按需 CREATE,见 references/CELL_PRESENTATION.md;未建表不影响 P0。
|
||||
INTERNAL_METADATA_TABLES = frozenset(
|
||||
{
|
||||
"_jiangchang_tables",
|
||||
"_jiangchang_columns",
|
||||
"_jiangchang_cell_presentation",
|
||||
"_jiangchang_grid_presentation",
|
||||
}
|
||||
)
|
||||
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 +29,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 +44,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 +55,88 @@ 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 → 主键。
|
||||
排序列须为真实物理列;**允许**引用 visible=0 的非敏感列(隐藏≠不能 ORDER BY)。
|
||||
|
||||
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 +147,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 +155,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 +179,7 @@ def upsert_column_metadata(
|
||||
column_name,
|
||||
display_name,
|
||||
description,
|
||||
grid_help,
|
||||
visible,
|
||||
searchable,
|
||||
editable,
|
||||
@@ -147,24 +189,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 +204,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 +284,32 @@ 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 drop_deprecated_user_table(
|
||||
cursor,
|
||||
table_name: str,
|
||||
*,
|
||||
triggers: Iterable[str] | None = None,
|
||||
) -> None:
|
||||
"""永久退役用户可见表:删 trigger、DROP 物理表、清理展示元数据(幂等)。
|
||||
|
||||
用于废弃 task_logs 等旧表。禁止只删元数据而保留物理表。
|
||||
见 references/SCHEMA.md「表增删改与退役」。
|
||||
"""
|
||||
for trigger_name in triggers or ():
|
||||
cursor.execute(f"DROP TRIGGER IF EXISTS {trigger_name}")
|
||||
default_updated_at = f"{table_name}_set_updated_at"
|
||||
cursor.execute(f"DROP TRIGGER IF EXISTS {default_updated_at}")
|
||||
cursor.execute(f"DROP TABLE IF EXISTS {_quote_identifier(table_name)}")
|
||||
cursor.execute(
|
||||
"DELETE FROM _jiangchang_columns WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
cursor.execute(
|
||||
"DELETE FROM _jiangchang_tables WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
|
||||
|
||||
def iter_user_tables(cursor) -> Iterable[str]:
|
||||
|
||||
@@ -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):
|
||||
@@ -218,6 +235,139 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_filter_search_metadata(
|
||||
cursor,
|
||||
table_name: str,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""对可见但未开启 searchable 的枚举 / boolean 列给出软建议。"""
|
||||
warnings: list[str] = []
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT column_name, visible, searchable, display_type, options_json
|
||||
FROM _jiangchang_columns
|
||||
WHERE table_name = ?
|
||||
""",
|
||||
(table_name,),
|
||||
)
|
||||
for column_name, visible, searchable, display_type, options_json in cursor.fetchall():
|
||||
if visible is not None and int(visible) == 0:
|
||||
continue
|
||||
if searchable is not None and int(searchable) != 0:
|
||||
continue
|
||||
|
||||
display_type_norm = str(display_type or "").strip()
|
||||
is_boolean = display_type_norm == "boolean"
|
||||
|
||||
has_enum = False
|
||||
if options_json is not None and str(options_json).strip():
|
||||
opt_errors, _ = validate_options_json(options_json)
|
||||
if not opt_errors:
|
||||
try:
|
||||
parsed = json.loads(str(options_json))
|
||||
except json.JSONDecodeError:
|
||||
parsed = None
|
||||
has_enum = isinstance(parsed, list) and len(parsed) > 0
|
||||
|
||||
if not has_enum and not is_boolean:
|
||||
continue
|
||||
|
||||
warnings.append(
|
||||
f"{table_name}.{column_name}: 枚举/状态类列建议 searchable=1,"
|
||||
"以便宿主列筛选与按中文 label 全局搜索(见 references/SCHEMA.md)"
|
||||
)
|
||||
|
||||
return [], warnings
|
||||
|
||||
|
||||
def parse_default_sort_json(raw: object) -> tuple[list[dict[str, str]], list[str]]:
|
||||
"""解析 default_sort_json;返回 (specs, errors)。specs 项含 column / direction。"""
|
||||
errors: list[str] = []
|
||||
if raw is None or str(raw).strip() == "":
|
||||
return [], []
|
||||
text = str(raw).strip()
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except json.JSONDecodeError:
|
||||
return [], ["default_sort_json 不是合法 JSON"]
|
||||
if not isinstance(parsed, list):
|
||||
return [], ["default_sort_json 必须是 JSON 数组"]
|
||||
out: list[dict[str, str]] = []
|
||||
for index, item in enumerate(parsed):
|
||||
if not isinstance(item, dict):
|
||||
errors.append(f"default_sort_json[{index}] 必须是对象")
|
||||
continue
|
||||
column = str(
|
||||
item.get("column")
|
||||
or item.get("columnName")
|
||||
or item.get("column_name")
|
||||
or ""
|
||||
).strip()
|
||||
direction_raw = str(item.get("direction") or "desc").strip().lower()
|
||||
if not column:
|
||||
errors.append(f"default_sort_json[{index}] 缺少 column")
|
||||
continue
|
||||
if direction_raw not in {"asc", "desc"}:
|
||||
errors.append(f"default_sort_json[{index}].direction 只能是 asc 或 desc")
|
||||
continue
|
||||
out.append({"column": column, "direction": direction_raw})
|
||||
return out, errors
|
||||
|
||||
|
||||
def validate_default_sort_metadata(
|
||||
cursor,
|
||||
table_name: str,
|
||||
*,
|
||||
physical_columns: list[str] | None = None,
|
||||
) -> tuple[list[str], list[str]]:
|
||||
"""校验表级 default_sort_json:列须真实存在;允许 visible=0;不存在则报错。
|
||||
|
||||
契约:visible=0 只藏界面,ORDER BY 仍可用非敏感物理列(含隐藏主键)。
|
||||
"""
|
||||
errors: list[str] = []
|
||||
warnings: list[str] = []
|
||||
table_cols = {
|
||||
str(row[1]) for row in cursor.execute("PRAGMA table_info(_jiangchang_tables)").fetchall()
|
||||
}
|
||||
if "default_sort_json" not in table_cols:
|
||||
return [], []
|
||||
cursor.execute(
|
||||
"SELECT default_sort_json FROM _jiangchang_tables WHERE table_name = ?",
|
||||
(table_name,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row:
|
||||
return [], []
|
||||
specs, parse_errors = parse_default_sort_json(row[0])
|
||||
for msg in parse_errors:
|
||||
errors.append(f"{table_name}: {msg}")
|
||||
if parse_errors:
|
||||
return errors, warnings
|
||||
|
||||
physical = set(physical_columns or list_physical_column_names(cursor, table_name))
|
||||
for spec in specs:
|
||||
column = spec["column"]
|
||||
if column not in physical:
|
||||
errors.append(
|
||||
f"{table_name}: default_sort_json 引用了不存在的列 {column!r}"
|
||||
)
|
||||
continue
|
||||
# visible=0 合法:不报错。仅提示契约,避免作者误删排序列。
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT visible FROM _jiangchang_columns
|
||||
WHERE table_name = ? AND column_name = ?
|
||||
""",
|
||||
(table_name, column),
|
||||
)
|
||||
meta = cursor.fetchone()
|
||||
if meta is not None and meta[0] is not None and int(meta[0]) == 0:
|
||||
warnings.append(
|
||||
f"{table_name}.{column}: default_sort_json 引用了 visible=0 列"
|
||||
"(合法:隐藏列仍可 ORDER BY;见 references/SCHEMA.md)"
|
||||
)
|
||||
return errors, warnings
|
||||
|
||||
|
||||
def validate_column_permissions(
|
||||
cursor,
|
||||
table_name: str,
|
||||
@@ -398,6 +548,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 +582,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 = ?",
|
||||
@@ -470,6 +644,17 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
|
||||
report.errors.extend(perm_errors)
|
||||
report.warnings.extend(perm_warnings)
|
||||
|
||||
_, filter_warnings = validate_filter_search_metadata(cur, table_name)
|
||||
report.warnings.extend(filter_warnings)
|
||||
|
||||
sort_errors, sort_warnings = validate_default_sort_metadata(
|
||||
cur,
|
||||
table_name,
|
||||
physical_columns=physical_columns,
|
||||
)
|
||||
report.errors.extend(sort_errors)
|
||||
report.warnings.extend(sort_warnings)
|
||||
|
||||
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
|
||||
for table_name, column_name in cur.fetchall():
|
||||
if table_name in METADATA_TABLES:
|
||||
@@ -500,11 +685,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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
79
scripts/service/html_page_service.py
Normal file
79
scripts/service/html_page_service.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""技能 HTML 快照:写入 `{skill_data}/html/*.html`(宿主扫描预览)。
|
||||
|
||||
契约要点(权威说明见 `references/ACTIONS.md`「HTML 页面报告」):
|
||||
|
||||
- 本模块只写快照。装完就有的壳子页放安装包 `assets/html/pages/*.html`,不要从这里拷过去
|
||||
- 快照目录:仅一层 `html/*.html`(非递归)
|
||||
- 重新生成 Action id 必须为 `render-html`(宿主硬编码;只针对快照)
|
||||
- 页面布局底线见 `references/ACTIONS.md`「宿主预览布局」;模板壳 `assets/html/example-report.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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -84,21 +83,6 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
task_type,
|
||||
)
|
||||
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",
|
||||
task_type,
|
||||
target,
|
||||
input_id,
|
||||
)
|
||||
finish(
|
||||
status="failed",
|
||||
message=reason,
|
||||
@@ -129,21 +113,6 @@ 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",
|
||||
@@ -153,10 +122,8 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int
|
||||
status="failed",
|
||||
message="模板仓库未实现真实业务",
|
||||
skill=SKILL_SLUG,
|
||||
template_demo=True,
|
||||
target=target,
|
||||
input_id=input_id,
|
||||
error_code="TEMPLATE_STUB",
|
||||
**summary_payload,
|
||||
)
|
||||
print("ERROR:TEMPLATE_STUB 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。")
|
||||
return rc
|
||||
@@ -170,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="任务执行异常,详见统一日志",
|
||||
@@ -205,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 ""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。"""
|
||||
|
||||
SKILL_SLUG = "your-skill-slug"
|
||||
SKILL_VERSION = "1.0.60"
|
||||
SKILL_VERSION = "1.0.113"
|
||||
LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.2"
|
||||
PLATFORM_KIT_MIN_VERSION = "1.2.11"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -7,7 +7,7 @@ import re
|
||||
|
||||
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
||||
_MAX_SLUG_LENGTH = 48
|
||||
_MIN_SEGMENTS = 3
|
||||
_MIN_SEGMENTS = 2
|
||||
_MAX_SEGMENTS = 5
|
||||
_MAX_NOUN_SEGMENTS = 3
|
||||
|
||||
@@ -60,7 +60,9 @@ _BUILTIN_PLATFORMS = frozenset(
|
||||
"etax",
|
||||
}
|
||||
)
|
||||
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"})
|
||||
_BUILTIN_SCOPES = frozenset({"all", "batch", "multi"})
|
||||
# 旧技能用 default 占位末段。不再当成合法 scope;校验只警告,避免同步后挡住已发布仓库。
|
||||
_DEPRECATED_PLATFORM_PLACEHOLDER = "default"
|
||||
|
||||
LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset(
|
||||
{
|
||||
@@ -117,11 +119,12 @@ def _match_platform_suffix(
|
||||
|
||||
def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
|
||||
"""
|
||||
返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid'
|
||||
返回: 'legacy_exempt' | 'standard' | 'scope' | 'plain' | 'invalid'
|
||||
|
||||
- legacy_exempt: slug in LEGACY_EXEMPT_SLUGS
|
||||
- standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5
|
||||
- scope: 末段 in scopes, 首段 in verbs, 3<=len<=5
|
||||
- standard: 末段匹配 platforms,首段 in verbs,2<=len<=5,且动词后至少还有一段名词
|
||||
- scope: 末段 in scopes(all/batch/multi),首段 in verbs,至少 verb-noun-scope
|
||||
- plain: 动词 + 名词,无平台、无 scope
|
||||
- invalid: 其他
|
||||
"""
|
||||
if slug in LEGACY_EXEMPT_SLUGS:
|
||||
@@ -138,12 +141,14 @@ def classify_slug_form(segments: list[str], *, slug: str = "") -> str:
|
||||
return "invalid"
|
||||
|
||||
if segments[-1] in scopes:
|
||||
if len(segments) < 3:
|
||||
return "invalid"
|
||||
return "scope"
|
||||
|
||||
if _match_platform_suffix(segments, platforms) is not None:
|
||||
return "standard"
|
||||
|
||||
return "invalid"
|
||||
return "plain"
|
||||
|
||||
|
||||
def validate_slug_semantics(
|
||||
@@ -173,8 +178,8 @@ def validate_slug_semantics(
|
||||
|
||||
if segment_count < _MIN_SEGMENTS:
|
||||
msg = (
|
||||
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform),"
|
||||
f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)"
|
||||
f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(动词-名词,平台可选),"
|
||||
f"当前为 {segment_count} 段:{slug!r}"
|
||||
)
|
||||
(errors if strict else warnings).append(msg)
|
||||
return errors, warnings
|
||||
@@ -197,9 +202,6 @@ def validate_slug_semantics(
|
||||
|
||||
form = classify_slug_form(segments, slug=slug)
|
||||
if form == "scope":
|
||||
if segments[-1] not in scopes:
|
||||
msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})"
|
||||
(errors if strict else warnings).append(msg)
|
||||
noun_count = segment_count - 2
|
||||
elif form == "standard":
|
||||
platform_match = _match_platform_suffix(segments, platforms)
|
||||
@@ -210,13 +212,17 @@ def validate_slug_semantics(
|
||||
else:
|
||||
_platform_name, platform_part_count = platform_match
|
||||
noun_count = segment_count - 1 - platform_part_count
|
||||
elif form == "plain":
|
||||
noun_count = segment_count - 1
|
||||
if segments[-1] == _DEPRECATED_PLATFORM_PLACEHOLDER:
|
||||
warnings.append(
|
||||
"slug 末段 default 已不再作为平台占位;没有外部平台时直接省略。"
|
||||
f"已发布的技能不用改名:{slug!r}"
|
||||
)
|
||||
else:
|
||||
if segment_count >= _MIN_SEGMENTS:
|
||||
if segments[-1] not in scopes:
|
||||
platform_match = _match_platform_suffix(segments, platforms)
|
||||
if platform_match is None:
|
||||
msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}"
|
||||
(errors if strict else warnings).append(msg)
|
||||
if segments and segments[-1] in scopes and segment_count < 3:
|
||||
msg = f"scope 型 slug 至少为动词-名词-scope:{slug!r}"
|
||||
(errors if strict else warnings).append(msg)
|
||||
noun_count = 0
|
||||
|
||||
if noun_count > _MAX_NOUN_SEGMENTS:
|
||||
|
||||
@@ -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_`` 前缀,不自动当用例收集) |
|
||||
|
||||
@@ -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],
|
||||
)
|
||||
|
||||
66
tests/samples/test_table_deprecation_migration.py.sample
Normal file
66
tests/samples/test_table_deprecation_migration.py.sample
Normal file
@@ -0,0 +1,66 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""旧库废弃表迁移样例(复制到 tests/ 并按技能改写)。
|
||||
|
||||
验收:给定旧库含废弃物理表 + 元数据 + 历史行 → init_db() 后表与元数据均清除。
|
||||
见 references/SCHEMA.md「表增删改与退役」。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from _support import IsolatedDataRoot
|
||||
|
||||
|
||||
class TestTableDeprecationMigration(unittest.TestCase):
|
||||
def test_init_db_drops_legacy_task_logs(self) -> None:
|
||||
with IsolatedDataRoot():
|
||||
from db.connection import get_conn, init_db
|
||||
from db.display_metadata import iter_user_tables
|
||||
|
||||
# 1) 模拟升级前的旧库
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE task_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
task_type TEXT,
|
||||
status TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO _jiangchang_tables
|
||||
(table_name, display_name, visible, readonly)
|
||||
VALUES ('task_logs', '任务日志', 1, 0)
|
||||
"""
|
||||
)
|
||||
cur.execute("INSERT INTO task_logs (task_type, status) VALUES ('run', 'ok')")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# 2) 新代码 init_db() 应幂等清除(在 connection.init_db 中调用 drop_deprecated_user_table)
|
||||
init_db()
|
||||
|
||||
conn = get_conn()
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
|
||||
)
|
||||
self.assertIsNone(cur.fetchone())
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'"
|
||||
)
|
||||
self.assertEqual(int(cur.fetchone()[0]), 0)
|
||||
self.assertNotIn("task_logs", list(iter_user_tables(cur)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -74,8 +74,10 @@ def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(bind, dict):
|
||||
return [f"{action_id}: bind must be object"]
|
||||
if set(bind.keys()) - {"tables"}:
|
||||
errors.append(f"{action_id}: bind only allows tables, got {sorted(bind.keys())}")
|
||||
allowed = {"tables", "inputMapping", "columnMapping", "labelColumn", "selectionMode"}
|
||||
unknown = set(bind.keys()) - allowed
|
||||
if unknown:
|
||||
errors.append(f"{action_id}: bind only allows {sorted(allowed)}, got {sorted(bind.keys())}")
|
||||
tables = bind.get("tables")
|
||||
if not isinstance(tables, list) or len(tables) < 1:
|
||||
errors.append(f"{action_id}: bind.tables must be non-empty array")
|
||||
@@ -90,6 +92,17 @@ def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
|
||||
if table in seen:
|
||||
errors.append(f"{action_id}: bind.tables duplicate {table!r}")
|
||||
seen.add(table)
|
||||
label_column = bind.get("labelColumn")
|
||||
if label_column is not None:
|
||||
if not isinstance(label_column, str) or not label_column.strip():
|
||||
errors.append(f"{action_id}: bind.labelColumn must be non-empty string")
|
||||
elif not _TABLE_NAME_PATTERN.fullmatch(label_column):
|
||||
errors.append(f"{action_id}: bind.labelColumn {label_column!r} must be snake_case")
|
||||
selection_mode = bind.get("selectionMode")
|
||||
if selection_mode is not None and selection_mode not in ("required", "none", "optional"):
|
||||
errors.append(
|
||||
f"{action_id}: bind.selectionMode must be required|none|optional, got {selection_mode!r}"
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
@@ -100,7 +113,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 +149,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 +197,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 +237,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 +367,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 +410,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",
|
||||
@@ -328,6 +445,136 @@ class TestActionsManifest(unittest.TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_schema_allows_bind_label_column(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.fail("jsonschema is required")
|
||||
schema = _load_actions_schema()
|
||||
bind_props = schema["$defs"]["bind"]["properties"]
|
||||
self.assertIn("labelColumn", bind_props)
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "probe-label-column",
|
||||
"label": "处理勾选",
|
||||
"description": "验证 bind.labelColumn 可通过 Schema",
|
||||
"placements": ["toolbar", "batch"],
|
||||
"executionProfile": "sync",
|
||||
"interaction": "confirm",
|
||||
"bind": {
|
||||
"tables": ["demo_items"],
|
||||
"inputMapping": {"ids": "$selection.ids"},
|
||||
"labelColumn": "display_name",
|
||||
},
|
||||
"entrypoint": {"type": "cli", "command": "health", "args": []},
|
||||
}
|
||||
],
|
||||
}
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_schema_allows_bind_selection_mode(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.fail("jsonschema is required")
|
||||
schema = _load_actions_schema()
|
||||
bind_props = schema["$defs"]["bind"]["properties"]
|
||||
self.assertIn("selectionMode", bind_props)
|
||||
self.assertEqual(
|
||||
bind_props["selectionMode"].get("enum"),
|
||||
["required", "none", "optional"],
|
||||
)
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "probe-selection-mode",
|
||||
"label": "处理队列",
|
||||
"description": "验证 bind.selectionMode 可通过 Schema",
|
||||
"placements": ["toolbar", "batch"],
|
||||
"executionProfile": "async",
|
||||
"interaction": "form",
|
||||
"bind": {
|
||||
"tables": ["demo_items"],
|
||||
"selectionMode": "optional",
|
||||
"inputMapping": {"itemIds": "$selection.ids"},
|
||||
"labelColumn": "display_name",
|
||||
},
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": ["itemIds", "pickCount"],
|
||||
"properties": {
|
||||
"itemIds": {"type": "string", "readOnly": True},
|
||||
"pickCount": {"type": "number", "default": 1, "minimum": 1},
|
||||
},
|
||||
},
|
||||
"entrypoint": {
|
||||
"type": "cli",
|
||||
"command": "process",
|
||||
"args": ["--ids", "{{itemIds}}", "--pick", "{{pickCount}}"],
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_action_examples_user_facing_copy_avoids_jargon(self) -> None:
|
||||
"""示例 Action 的用户可见字符串不得含常见开发黑话(属性名本身除外)。"""
|
||||
examples_dir = os.path.join(get_skill_root(), "assets", "examples")
|
||||
# 故意拆开,避免本测试源码触发自身扫描。
|
||||
forbidden = (
|
||||
"pickCount",
|
||||
"itemIds",
|
||||
"status=",
|
||||
"import-urls",
|
||||
"download-enqueue",
|
||||
"bind.selectionMode",
|
||||
"selectionMode=",
|
||||
)
|
||||
# async/pending 作为整词出现在 description 才禁止;executionProfile 值除外。
|
||||
word_forbidden = re.compile(r"\b(async|pending|sync)\b", re.I)
|
||||
|
||||
for name in os.listdir(examples_dir):
|
||||
if not name.endswith(".action.example.json"):
|
||||
continue
|
||||
path = os.path.join(examples_dir, name)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
actions = data["actions"] if isinstance(data, dict) and "actions" in data else [data]
|
||||
for action in actions:
|
||||
if not isinstance(action, dict):
|
||||
continue
|
||||
texts: list[str] = []
|
||||
for key in ("description",):
|
||||
if action.get(key):
|
||||
texts.append(str(action[key]))
|
||||
conf = action.get("confirmation") or {}
|
||||
if isinstance(conf, dict) and conf.get("message"):
|
||||
texts.append(str(conf["message"]))
|
||||
props = ((action.get("inputSchema") or {}).get("properties")) or {}
|
||||
for prop in props.values():
|
||||
if not isinstance(prop, dict):
|
||||
continue
|
||||
for key in ("title", "description"):
|
||||
if prop.get(key):
|
||||
texts.append(str(prop[key]))
|
||||
blob = "\n".join(texts)
|
||||
for token in forbidden:
|
||||
self.assertNotIn(
|
||||
token,
|
||||
blob,
|
||||
msg=f"{name} user-facing copy must not contain {token!r}",
|
||||
)
|
||||
match = word_forbidden.search(blob)
|
||||
if match is not None:
|
||||
self.fail(
|
||||
f"{name} user-facing copy must not contain {match.group(0)!r}"
|
||||
)
|
||||
|
||||
def test_no_execution_profile_placement_coupling_in_tests(self) -> None:
|
||||
"""守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。"""
|
||||
path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py")
|
||||
@@ -469,8 +716,53 @@ class TestActionsManifest(unittest.TestCase):
|
||||
"additionalProperties",
|
||||
"placements",
|
||||
"executionProfile",
|
||||
"interaction",
|
||||
"confirmation",
|
||||
"数据管理技能按钮顺序",
|
||||
"数组相对顺序",
|
||||
"全部直接展示",
|
||||
"左右分栏",
|
||||
"行级入口",
|
||||
"行右键",
|
||||
"六职责正交",
|
||||
"POLICY.md",
|
||||
"hostEffects",
|
||||
"steps",
|
||||
"hoverHint",
|
||||
"visibleWhen",
|
||||
"字段条件显隐",
|
||||
"reloadTable",
|
||||
"afterSuccess",
|
||||
"afterFailure",
|
||||
"cellPresentation",
|
||||
"CELL_PRESENTATION.md",
|
||||
"整行",
|
||||
"labelColumn",
|
||||
"selectionMode",
|
||||
"勾选驱动",
|
||||
"用户可见文案",
|
||||
"双受众",
|
||||
"已选择",
|
||||
"revealInForm",
|
||||
"映射只读",
|
||||
):
|
||||
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",
|
||||
|
||||
164
tests/test_cell_presentation_contract.py
Normal file
164
tests/test_cell_presentation_contract.py
Normal file
@@ -0,0 +1,164 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Cell / Grid Presentation 契约:schema + 文档 + 示例。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import unittest
|
||||
|
||||
from util.runtime_paths import get_skill_root
|
||||
|
||||
|
||||
def _skill_path(*parts: str) -> str:
|
||||
return os.path.join(get_skill_root(), *parts)
|
||||
|
||||
|
||||
def _load_json(*parts: str) -> dict:
|
||||
with open(_skill_path(*parts), encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _load_cell_schema() -> dict:
|
||||
return _load_json("assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
|
||||
|
||||
def _load_cell_example() -> dict:
|
||||
return _load_json("assets", "examples", "skill-cell-presentation.example.json")
|
||||
|
||||
|
||||
def _load_grid_schema() -> dict:
|
||||
return _load_json("assets", "schemas", "skill-grid-presentation.schema.json")
|
||||
|
||||
|
||||
def _load_grid_example() -> dict:
|
||||
return _load_json("assets", "examples", "skill-grid-presentation.example.json")
|
||||
|
||||
|
||||
class TestCellPresentationContract(unittest.TestCase):
|
||||
def test_schema_file_exists_and_parses(self) -> None:
|
||||
schema = _load_cell_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_cell_example(), schema=_load_cell_schema())
|
||||
|
||||
def test_actions_schema_defines_cell_presentation_enable_block(self) -> None:
|
||||
actions_schema = _load_json("assets", "schemas", "skill-actions.schema.json")
|
||||
self.assertIn("cellPresentation", actions_schema["properties"])
|
||||
self.assertIn("cellPresentationDeclaration", actions_schema["$defs"])
|
||||
|
||||
def test_docs_and_example_exist(self) -> None:
|
||||
self.assertTrue(os.path.isfile(_skill_path("references", "CELL_PRESENTATION.md")))
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
_skill_path("assets", "schemas", "skill-cell-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
_skill_path("assets", "examples", "skill-cell-presentation.example.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
_skill_path("assets", "schemas", "skill-grid-presentation.schema.json")
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
os.path.isfile(
|
||||
_skill_path("assets", "examples", "skill-grid-presentation.example.json")
|
||||
)
|
||||
)
|
||||
|
||||
def test_cell_presentation_md_covers_contract(self) -> None:
|
||||
path = _skill_path("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",
|
||||
"_jiangchang_grid_presentation",
|
||||
"cellPresentation",
|
||||
"派生",
|
||||
"任意 CSS",
|
||||
"用途不限",
|
||||
"schemaVersion",
|
||||
"整行",
|
||||
"整列",
|
||||
"all",
|
||||
"格子 > 区域 > 行 > 列",
|
||||
"详情",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"CELL_PRESENTATION.md missing {marker!r}")
|
||||
|
||||
def test_grid_schema_file_exists_and_parses(self) -> None:
|
||||
schema = _load_grid_schema()
|
||||
self.assertEqual(schema.get("title"), "SkillGridPresentationDocument")
|
||||
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"})
|
||||
self.assertIn("not", schema["$defs"]["ruleItem"])
|
||||
|
||||
def test_grid_example_validates_against_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
jsonschema.validate(instance=_load_grid_example(), schema=_load_grid_schema())
|
||||
|
||||
def test_grid_schema_rejects_all_by_all(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
payload = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"table": "business_records",
|
||||
"rowKey": "id",
|
||||
"rules": [
|
||||
{
|
||||
"ruleKey": "paint-everything",
|
||||
"rows": "all",
|
||||
"columns": "all",
|
||||
"tone": "danger",
|
||||
}
|
||||
],
|
||||
}
|
||||
with self.assertRaises(jsonschema.ValidationError):
|
||||
jsonschema.validate(instance=payload, schema=_load_grid_schema())
|
||||
|
||||
def test_internal_metadata_hides_presentation_tables(self) -> None:
|
||||
from db.display_metadata import INTERNAL_METADATA_TABLES, METADATA_TABLES
|
||||
|
||||
self.assertIn("_jiangchang_cell_presentation", INTERNAL_METADATA_TABLES)
|
||||
self.assertIn("_jiangchang_grid_presentation", INTERNAL_METADATA_TABLES)
|
||||
self.assertTrue(INTERNAL_METADATA_TABLES <= METADATA_TABLES)
|
||||
|
||||
def test_template_actions_do_not_enable_presentation_by_default(self) -> None:
|
||||
manifest = _load_json("assets", "actions.json")
|
||||
self.assertNotEqual(manifest.get("cellPresentation", {}).get("enabled"), True)
|
||||
|
||||
def test_checklist_covers_grid_presentation(self) -> None:
|
||||
path = _skill_path("development", "CHECKLIST.md")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
self.assertIn("A6-15", text)
|
||||
self.assertIn("cellPresentation", text)
|
||||
self.assertIn("CELL_PRESENTATION.md", text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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
|
||||
@@ -14,7 +14,7 @@ from jiangchang_skill_core import config
|
||||
|
||||
# scripts/ 已由 _support 注入 sys.path
|
||||
from cli.app import main
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.constants import SKILL_SLUG, SKILL_VERSION
|
||||
|
||||
|
||||
class TestCliSmoke(unittest.TestCase):
|
||||
@@ -57,6 +57,7 @@ class TestCliSmoke(unittest.TestCase):
|
||||
self.assertIn("version", payload)
|
||||
self.assertIn("skill", payload)
|
||||
self.assertEqual(payload["skill"], SKILL_SLUG)
|
||||
self.assertEqual(payload["version"], SKILL_VERSION)
|
||||
|
||||
def test_init_db_creates_database_and_is_idempotent(self) -> None:
|
||||
with IsolatedDataRoot(user_id="_cli_init_db"):
|
||||
@@ -78,20 +79,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。"""
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -40,6 +40,7 @@ POLICY_IDS = (
|
||||
"POLICY-RPA-006",
|
||||
"POLICY-PACKAGING-001",
|
||||
"POLICY-PACKAGING-002",
|
||||
"POLICY-RELEASE-001",
|
||||
"POLICY-DOCS-001",
|
||||
"POLICY-LOGGING-001",
|
||||
"POLICY-LOGGING-002",
|
||||
@@ -57,6 +58,7 @@ POLICY_IDS = (
|
||||
"POLICY-SKILL-ACTION-002",
|
||||
"POLICY-SKILL-ACTION-003",
|
||||
"POLICY-SKILL-ACTION-004",
|
||||
"POLICY-TUTORIAL-001",
|
||||
"POLICY-ARCH-CORE-001",
|
||||
"POLICY-DATA-SYNC-001",
|
||||
)
|
||||
@@ -876,7 +878,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)
|
||||
@@ -886,8 +888,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,
|
||||
[],
|
||||
@@ -1531,6 +1531,44 @@ class TestPolicyDataSync001(unittest.TestCase):
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyRelease001(unittest.TestCase):
|
||||
def test_release_ps1_enforces_version_alignment(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
path = os.path.join(skill_root, "release.ps1")
|
||||
self.assertTrue(
|
||||
os.path.isfile(path),
|
||||
msg=_policy_msg("POLICY-RELEASE-001", "release.ps1", "file missing"),
|
||||
)
|
||||
text = _read_text(path)
|
||||
self.assertIn(
|
||||
"Assert-SkillReleaseVersionAlignment",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RELEASE-001",
|
||||
"release.ps1",
|
||||
"must define Assert-SkillReleaseVersionAlignment",
|
||||
),
|
||||
)
|
||||
self.assertIn(
|
||||
"POLICY-RELEASE-001",
|
||||
text,
|
||||
msg=_policy_msg(
|
||||
"POLICY-RELEASE-001",
|
||||
"release.ps1",
|
||||
"must reference POLICY-RELEASE-001",
|
||||
),
|
||||
)
|
||||
self.assertRegex(
|
||||
text,
|
||||
r"Assert-SkillReleaseVersionAlignment\s+-SkillRoot",
|
||||
msg=_policy_msg(
|
||||
"POLICY-RELEASE-001",
|
||||
"release.ps1",
|
||||
"must invoke Assert-SkillReleaseVersionAlignment before tagging",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPolicyDocs001(unittest.TestCase):
|
||||
def test_policy_matrix_exists_and_lists_policy_ids(self) -> None:
|
||||
skill_root = get_skill_root()
|
||||
|
||||
@@ -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,14 +553,304 @@ metadata:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
def test_schema_md_documents_task_logs_readonly_semantics(self) -> None:
|
||||
def test_enum_column_without_searchable_warns(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE filter_demo (id INTEGER PRIMARY KEY, enabled INTEGER NOT NULL DEFAULT 1);
|
||||
"""
|
||||
)
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
upsert_column_metadata,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
from db.display_metadata_validator import validate_filter_search_metadata
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
upsert_table_metadata(cur, table_name="filter_demo", display_name="筛选演示", readonly=0)
|
||||
upsert_column_metadata(
|
||||
cur,
|
||||
table_name="filter_demo",
|
||||
column_name="enabled",
|
||||
display_name="是否启用",
|
||||
visible=1,
|
||||
searchable=0,
|
||||
editable=1,
|
||||
display_type="boolean",
|
||||
options_json='[{"value":0,"label":"停用"},{"value":1,"label":"启用"}]',
|
||||
)
|
||||
conn.commit()
|
||||
_, warnings = validate_filter_search_metadata(cur, "filter_demo")
|
||||
self.assertTrue(any("searchable=1" in item for item in warnings))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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)
|
||||
self.assertIn("可以**引用 `visible=0` 的列", text)
|
||||
self.assertIn("default_sort_json` / ORDER BY", text)
|
||||
|
||||
def test_default_sort_json_allows_hidden_column_and_rejects_missing(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
from db.display_metadata import (
|
||||
init_display_metadata_tables,
|
||||
seed_column_metadata_batch,
|
||||
upsert_table_metadata,
|
||||
)
|
||||
from db.display_metadata_validator import validate_default_sort_metadata
|
||||
|
||||
cur = conn.cursor()
|
||||
init_display_metadata_tables(cur)
|
||||
cur.execute(
|
||||
"CREATE TABLE demo_sort (id INTEGER PRIMARY KEY, title TEXT NOT NULL)"
|
||||
)
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
display_name="排序示例",
|
||||
default_sort_json='[{"column":"title","direction":"asc"},{"column":"id","direction":"desc"}]',
|
||||
)
|
||||
seed_column_metadata_batch(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
columns=(
|
||||
{"column_name": "id", "display_name": "编号", "visible": 0, "editable": 0},
|
||||
{"column_name": "title", "display_name": "标题", "visible": 1, "editable": 1},
|
||||
),
|
||||
valid_column_names=["id", "title"],
|
||||
)
|
||||
errors, warnings = validate_default_sort_metadata(cur, "demo_sort")
|
||||
self.assertEqual(errors, [])
|
||||
self.assertTrue(any("visible=0" in w and "id" in w for w in warnings))
|
||||
|
||||
upsert_table_metadata(
|
||||
cur,
|
||||
table_name="demo_sort",
|
||||
display_name="排序示例",
|
||||
default_sort_json='[{"column":"missing_col","direction":"asc"}]',
|
||||
)
|
||||
errors, _ = validate_default_sort_metadata(cur, "demo_sort")
|
||||
self.assertTrue(any("missing_col" in e for e in errors))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
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()
|
||||
|
||||
def test_drop_deprecated_user_table_removes_physical_and_metadata(self) -> None:
|
||||
conn = sqlite3.connect(":memory:")
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE _jiangchang_tables (
|
||||
table_name TEXT PRIMARY KEY,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
sort_order INTEGER,
|
||||
visible INTEGER,
|
||||
readonly INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE _jiangchang_columns (
|
||||
table_name TEXT,
|
||||
column_name TEXT,
|
||||
display_name TEXT,
|
||||
description TEXT,
|
||||
display_order INTEGER,
|
||||
visible INTEGER,
|
||||
searchable INTEGER,
|
||||
editable INTEGER,
|
||||
display_type TEXT,
|
||||
options_json TEXT,
|
||||
PRIMARY KEY (table_name, column_name)
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"""
|
||||
CREATE TABLE task_logs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
status TEXT,
|
||||
created_at INTEGER,
|
||||
updated_at INTEGER
|
||||
)
|
||||
"""
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO _jiangchang_tables VALUES ('task_logs', '任务日志', NULL, 1, 1, 0)"
|
||||
)
|
||||
cur.execute(
|
||||
"INSERT INTO _jiangchang_columns VALUES "
|
||||
"('task_logs', 'id', '编号', NULL, 1, 1, 1, 0, 'text', NULL)"
|
||||
)
|
||||
cur.execute("INSERT INTO task_logs (status) VALUES ('success')")
|
||||
|
||||
from db.display_metadata import drop_deprecated_user_table, iter_user_tables
|
||||
|
||||
drop_deprecated_user_table(cur, "task_logs")
|
||||
cur.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
|
||||
)
|
||||
self.assertIsNone(cur.fetchone())
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name='task_logs'"
|
||||
)
|
||||
self.assertEqual(int(cur.fetchone()[0]), 0)
|
||||
self.assertNotIn("task_logs", list(iter_user_tables(cur)))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -181,6 +181,18 @@ class TestDocsStandards(unittest.TestCase):
|
||||
self.assertIn("CHANGELOG.md", text)
|
||||
self.assertIn("demo_video_url", text)
|
||||
self.assertIn("tutorial_md", text)
|
||||
self.assertIn("TUTORIAL_SURFACES.md", text)
|
||||
|
||||
def test_tutorial_surfaces_contract_doc_and_checklist(self) -> None:
|
||||
surfaces = self._read("development/TUTORIAL_SURFACES.md")
|
||||
self.assertIn("说明", surfaces)
|
||||
self.assertIn("教程", surfaces)
|
||||
self.assertIn("skill-detail", surfaces)
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("A2-7", checklist)
|
||||
self.assertIn("A2-8", checklist)
|
||||
self.assertIn("A2-12", checklist)
|
||||
self.assertIn("TUTORIAL_SURFACES.md", checklist)
|
||||
|
||||
def test_naming_md_exists_and_covers_standards(self) -> None:
|
||||
text = self._read("development/NAMING.md")
|
||||
@@ -299,9 +311,27 @@ 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, "assets", "schemas", "skill-grid-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")))
|
||||
|
||||
@@ -325,9 +355,93 @@ 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 多选",
|
||||
"数据管理:列筛选与全局搜索",
|
||||
"列筛选与全局搜索",
|
||||
"searchable: 1",
|
||||
"筛选栏控件",
|
||||
"表增删改与退役",
|
||||
"drop_deprecated_user_table",
|
||||
"孤儿物理表",
|
||||
"_jiangchang_grid_presentation",
|
||||
):
|
||||
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)
|
||||
self.assertIn("selection-mode-optional.action.example.json", text)
|
||||
self.assertIn("skill-cell-presentation.example.json", text)
|
||||
self.assertIn("skill-grid-presentation.example.json", text)
|
||||
checklist = self._read("development/CHECKLIST.md")
|
||||
self.assertIn("A4-7", checklist)
|
||||
self.assertIn("visibleWhen", checklist)
|
||||
self.assertIn("A4-8", checklist)
|
||||
self.assertIn("revealInForm", checklist)
|
||||
self.assertIn("A4-9", checklist)
|
||||
self.assertIn("selectionMode", checklist)
|
||||
self.assertIn("A4-10", checklist)
|
||||
self.assertIn("用户可见文案", checklist)
|
||||
runtime = self._read("development/SKILL_ACTION_RUNTIME.md")
|
||||
self.assertIn("visibleWhen", runtime)
|
||||
self.assertIn("revealInForm", runtime)
|
||||
self.assertIn("selectionMode", runtime)
|
||||
self.assertIn("用户可见文案", runtime)
|
||||
actions = self._read("references/ACTIONS.md")
|
||||
self.assertIn("用户可见文案", actions)
|
||||
self.assertIn("双受众", actions)
|
||||
|
||||
def test_guarded_docs_do_not_reference_amazon_implementation(self) -> None:
|
||||
forbidden = (
|
||||
"download-settlement-report-amazon",
|
||||
@@ -424,6 +538,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:
|
||||
|
||||
89
tests/test_html_page_service.py
Normal file
89
tests/test_html_page_service.py
Normal file
@@ -0,0 +1,89 @@
|
||||
# -*- 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>")
|
||||
|
||||
def test_example_report_shell_embedded_preview_layout(self) -> None:
|
||||
"""守护 example-report.html:数据管理 iframe 预览布局基线不退化。"""
|
||||
template = load_html_template("assets/html/example-report.html")
|
||||
self.assertIn("overflow-x: hidden", template)
|
||||
self.assertIn("min-width: 0", template)
|
||||
self.assertIn("table-layout: fixed", template)
|
||||
self.assertIn("overflow-wrap: anywhere", template)
|
||||
self.assertNotIn("max-width:", template)
|
||||
|
||||
def test_actions_contract_keeps_shell_pages_and_call(self) -> None:
|
||||
from util.runtime_paths import get_skill_root
|
||||
|
||||
root = get_skill_root()
|
||||
actions_path = os.path.join(root, "references", "ACTIONS.md")
|
||||
with open(actions_path, encoding="utf-8") as f:
|
||||
actions = f.read()
|
||||
self.assertIn("assets/html/pages", actions)
|
||||
self.assertIn("jiangchang.call", actions)
|
||||
self.assertTrue(os.path.isfile(os.path.join(root, "assets", "html", "example-report.html")))
|
||||
self.assertFalse(
|
||||
os.path.isfile(os.path.join(root, "assets", "html", "pages", "example-report.html"))
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -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:
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user