Compare commits

...

50 Commits

Author SHA1 Message Date
a70da6a076 feat: precipitate grid presentation (row/column/region) into skill contract
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-09-20 21:08:23 +08:00
a125b00285 Release v1.0.110: 沉淀壳子页与页面调用契约
All checks were successful
技能自动化发布 / release (push) Successful in 9s
2026-09-19 16:23:23 +08:00
2a3e971c76 docs: document JobStopped and lease cleanup contract for interrupt
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-17 13:26:28 +08:00
db2ed089a3 Release v1.0.108: precipitate user-facing copy contract
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-12 14:08:33 +08:00
c43c26ad15 Release v1.0.107: precipitate bind.selectionMode contract
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-09-12 13:42:34 +08:00
fe4209b37f Release v1.0.106: document optional OEM brands metadata
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-09-07 15:40:31 +08:00
3bebd6468f Document form selection-summary readOnly folding contract (revealInForm)
All checks were successful
技能自动化发布 / release (push) Successful in 8s
2026-09-03 14:50:29 +08:00
e7fa3c79b5 Assert CLI version output matches SKILL_VERSION 2026-09-03 13:52:00 +08:00
0ce0c3e852 Enforce POLICY-RELEASE-001: align git tag with SKILL.md and SKILL_VERSION
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-03 13:51:00 +08:00
201d4d1367 Release v1.0.103: forbid redundant market-doc H1
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-09-03 10:32:41 +08:00
295b2356ac docs: deposit selection summary and bind.labelColumn contract
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-09-02 18:37:33 +08:00
7257d09813 Release v1.0.101: Action file/directory pathRoot contract
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-31 19:14:01 +08:00
9d33c5ea2c docs: sediment TUTORIAL_SURFACES contract for README vs tutorial and three entry surfaces
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-31 14:18:42 +08:00
b5d120469b Release skill-template v1.0.99: table registry contract and drop_deprecated_user_table
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-25 13:40:08 +08:00
d35d04f779 Release v1.0.98: data management filter and global search contract
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-23 17:10:52 +08:00
cbebd19ab3 docs(html): 沉淀数据管理 iframe 预览布局契约
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-08-22 16:35:05 +08:00
05764cac15 docs: add Action label naming guidance in ACTIONS.md (v1.0.96)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-22 11:52:41 +08:00
0a6b0171d8 docs: settle work item lifecycle contract (v1.0.95)
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-21 16:30:34 +08:00
43486afec4 docs: dedupe changelog after lookup.multiple contract (v1.0.94)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-21 16:20:53 +08:00
225d5c0777 docs: add lookup.multiple multi-select contract (v1.0.93)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-21 16:19:13 +08:00
fbf326d525 Document replaces:import and add CRUD/import action examples
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-21 11:57:38 +08:00
28c3c8d8ab Release v1.0.91: document no SQLite FK between business tables
All checks were successful
技能自动化发布 / release (push) Successful in 4s
2026-08-19 16:03:42 +08:00
571c135114 Release v1.0.90: document grid_help data-management contract
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-18 11:00:20 +08:00
d840745758 chore: add MIT license, SECURITY.md, and placeholder developer_ids
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:44:56 +08:00
cf3f0ae990 fix: default git tag message from UTF-8 bytes for Windows PowerShell 5.1
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:19:47 +08:00
a11cdf4e7e fix: write git tag/commit messages via UTF-8 -F file
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-18 10:16:33 +08:00
81096051dd fix: invoke git without cmd.exe so annotated tags keep UTF-8
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-18 10:13:28 +08:00
f221f81d4e docs: add PRODUCT_SURFACES peer entry contract for skill authors
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-17 18:49:05 +08:00
3738f0bea2 docs: 沉淀 Action 表单 visibleWhen 条件显隐契约
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-16 14:26:56 +08:00
6ebc570632 docs: precipitate Action/CRUD Lookup contract for skill authors
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-16 12:58:39 +08:00
2d02ccff5d Release v1.0.82: add optional hoverHint contract for toolbar skill actions
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-08-15 10:02:27 +08:00
25b0c62dea fix: drop default task_logs; process in log, progress in task center (v1.0.81)
All checks were successful
技能自动化发布 / release (push) Successful in 7s
2026-08-14 20:53:25 +08:00
ff75aefcdd docs: precipitate HTML report contract for data management
All checks were successful
技能自动化发布 / release (push) Successful in 6s
2026-08-14 15:52:06 +08:00
49e4d6e9a6 docs: default visible columns contract (v1.0.79)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
沉淀 _jiangchang_tables.default_visible_columns_json:技能声明默认展示列;未配置全显;用户改「列」后本机覆盖;与 visible=0 硬隐藏区分。

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

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

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

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-07 11:19:14 +08:00
46bc573777 Release v1.0.66: table default_sort_json for data management
All checks were successful
技能自动化发布 / release (push) Successful in 10s
2026-08-02 12:17:55 +08:00
0002e86729 docs: columnMapping for replaces create/update lookup reuse
All checks were successful
技能自动化发布 / release (push) Successful in 5s
2026-07-31 15:14:44 +08:00
94 changed files with 6890 additions and 1063 deletions

View File

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

View File

@@ -9,6 +9,275 @@
- 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节
- 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段
## 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

10
DEMO.md
View File

@@ -15,9 +15,13 @@ demo_video_url: ""
## 建议观看顺序
1. 如何发起一次典型任务
2. 执行过程中会看到什么提示 / 进度
3. 成功后的结果长什么样
建议与 `TUTORIAL.md` 三入口结构对齐(有则拍、无则跳过):
1. 数据管理:准备数据 → 主流程按钮 → 结果表/报告
2. 对话任务(若有):一句自然语言触发同一能力
3. 定时任务(若有):到点自动跑;若本技能无定时,演示中可说明「不需要定时」
成功后的结果长什么样(表、任务中心、HTML 等)
## 没有视频时

21
LICENSE Normal file
View File

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

View File

@@ -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
View File

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

View File

@@ -1,17 +1,19 @@
---
name: 技能开发模板(通用业务版)
description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。"
version: 1.0.64
version: 1.0.111
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,7 +152,7 @@ python {baseDir}/scripts/main.py init-db
1. 从本模板创建新业务技能时,**不得保留模板 `.git`**;优先使用 [`tools/scaffold_skill.ps1`](tools/scaffold_skill.ps1)(见 [`tools/README.md`](tools/README.md))。
2. 业务技能仓库中**不得**存在 `.openclaw-skill-template`(仅 template 源仓库保留)。
3. 复制目录为你的新 skill 仓库,全局替换 `your-skill-slug` 等占位词。
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制。
4. 按 `development/DEVELOPMENT.md` 完成开发与文档定制;开发前先读 `development/PRODUCT_SURFACES.md` 并填写入口定位表。
5. 用户市场四 Tab 材料写入根 `README.md` / `TUTORIAL.md` / `DEMO.md` / `CHANGELOG.md`;Agent 调用契约见 `references/CLI.md`、`references/SCHEMA.md`、`references/ACTIONS.md`。
6. 同步修改 `scripts/util/constants.py` 中的 `SKILL_SLUG` / `SKILL_VERSION`。
7. 本地 SQLite 的中文表名/字段名、标准时间字段与字段顺序规范见 `references/SCHEMA.md`。
@@ -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」。

View File

@@ -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 任务看进度 |
| 【网页自动化】未登录 / 无权限 | 确认账号在线与目标站登录状态 |
| 结果不对 | 缩小范围重试,或改用数据管理核对表数据 |
仍无法解决时,把「你说了什么 + 界面提示原文」发给支持人员,便于排查。
仍无法解决时,把「你用的入口 + 操作步骤 + 界面提示原文」发给支持人员。

View File

@@ -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/)

View File

@@ -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`」)。

View File

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

View File

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

View File

@@ -0,0 +1,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" }]
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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"]
}
}

View 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"]
}
}

View File

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

View File

@@ -0,0 +1,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
}
]
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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>

View File

@@ -11,3 +11,4 @@ lianlian
single-window
export-rebate
etax
colipu

View File

@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-actions.schema.json",
"title": "SkillActionManifest",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。",
"description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。含可选 hostEffects/steps(匠厂宿主 ≥2.8.5)与可选 hoverHint(数据管理工具栏悬停,需宿主已支持该字段)。",
"type": "object",
"required": ["schemaVersion", "skill", "actions"],
"additionalProperties": false,
@@ -19,19 +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"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。"
"enum": ["create", "update", "delete", "import"],
"description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除、import=导入。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。import 时 UI 仍用宿主「导入数据」面板,确认后调用本 Action 写库。"
},
"scalarArg": {
"type": ["string", "number", "boolean"]
@@ -56,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"],
@@ -125,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",
@@ -147,11 +407,16 @@
"type": "string",
"minLength": 1
},
"hoverHint": {
"type": "string",
"minLength": 1,
"description": "可选。数据管理工具栏悬停文案。与 description / confirmation.message 独立;省略则不显示悬停,不回退。需宿主已支持该字段。当前实现为顶栏技能按钮,不含宿主内置删除/刷新/新增/导入/导出。"
},
"placements": {
"type": "array",
"minItems": 0,
"items": { "$ref": "#/$defs/placement" },
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。"
"description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。数据管理 toolbar/row/batch 的顺序由顶层 actions 数组相对顺序决定。表顶栏 toolbar/batch 全部直接展示(无溢出菜单);row 为行级入口(操作列/行右键/⋯),不在本字段配置排序、截断或菜单树。"
},
"replaces": { "$ref": "#/$defs/mutationKind" },
"entrypoint": { "$ref": "#/$defs/entrypoint" },
@@ -179,11 +444,30 @@
"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"] }
"delete": { "type": "string", "enum": ["direct", "confirm", "form"] },
"import": { "type": "string", "enum": ["direct", "confirm", "form"] }
},
"description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。"
},
"bind": { "$ref": "#/$defs/bind" }
"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": {

View File

@@ -0,0 +1,78 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://openclaw.local/skill-template/skill-cell-presentation.schema.json",
"title": "SkillCellPresentationDocument",
"description": "匠厂数据管理「单元格呈现」文档(schemaVersion 1)。技能按业务规则为「某表某行某列」声明语义 tone(非任意 CSS);宿主映射主题样式。整行/整列/区域见 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);填完数据并重算后该项消失则提醒自然关闭"
}
}
}
}
}

View 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 = 按本条规则计入进页/同步提醒(不按命中格子数)"
}
}
}
}
}

View File

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

View File

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

View File

@@ -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 或验证码。

View File

@@ -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
```
## 相关文档

View File

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

View File

@@ -52,11 +52,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** 开发者按平台规则能安装并测到该技能(若平台有开发者可见性要求)
---

View File

@@ -104,25 +104,28 @@ STEP_DELAY_MAX=3.0 # 单位:秒;默认与最短等待组
# 人工处理超时:遇到滑块、验证码、二次验证时,等待人工处理的最长时间
HUMAN_WAIT_TIMEOUT=180 # 单位:秒;来不及处理时可调大,如 300
# 浏览器启动超时(可选):限制 launch 最长时间;注释掉或不设=不限制
# OPENCLAW_BROWSER_LAUNCH_TIMEOUT_MS=60000 # 单位:毫秒;慢机器时可开启
```
四档运行模式的**实现与测试约定**见 [`ADAPTER.md`](ADAPTER.md);录屏管线见 [`RPA.md`](RPA.md)。这些内容**不要**写回 `.env.example` 注释。
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_DOWNLOAD_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
**业务专属配置必须带技能前缀**(如 `DEMO_XXX`、`MY_SKILL_XXX`、`SKILL_OUTPUT_DIR`),**不要污染全局命名空间**(禁止 `SCRAPE_1688_*`、`RECEIVE_ORDER_*` 等跨技能前缀写入模板)。
### 数据目录子路径(下载 / 导入 / 导出)
### 数据目录子路径(输入 / 输出)
技能**自己写入**的文件默认在 `{JIANGCHANG_DATA_ROOT}/{USER_ID}/{slug}/` 下标准子目录(`downloads/`、`imports/`、`exports/` 等)。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_*_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
用户可见目录:文件浏览只有 **`input/`(上行)** 与 **`output/`(下行)**;另有可选 **`html/`** 供宿主扫描 HTML 报告页(见 `DATA_PATHS.md`)。技能写入的业务文件产出默认落在 `output/`;整页报告落在 `html/`;用户放入的待处理文件落在 `input/`。须通过 `util.runtime_paths.resolve_data_path()` 或 `get_input_dir()` / `get_output_dir()` / `get_html_dir()` 解析,**禁止** `os.path.abspath(config.get(...))` 相对 CWD。
- 权威说明(开发):[`DATA_PATHS.md`](DATA_PATHS.md)
- `.env.example` 中路径类配置**留空或保持注释即用默认**;若写相对路径,必须相对 **skill 数据目录**,**禁止** `./outputs/...` 这类相对 workspace 的写法
- 可选 env:`SKILL_DOWNLOAD_DIR`、`SKILL_IMPORT_DIR`、`SKILL_EXPORT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
- 可选 env:`SKILL_INPUT_DIR`、`SKILL_OUTPUT_DIR` 等;若作为活跃 key 出现在配置管理中,同样须有用户向行头/行尾(见模板根 `.env.example`)
### 录屏开关(`OPENCLAW_RECORD_VIDEO`)
- **模板默认 `OPENCLAW_RECORD_VIDEO=0`**,降低本地开发 / 调试成本(不启 ffmpeg、不生成 MP4)。
- 需要**生产留证、合规记录、客户现场复现或排障**时,可在用户 `.env` 或进程环境变量中设为 `OPENCLAW_RECORD_VIDEO=1`。
- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `result_summary`(见 `RPA.md` §5.3)。
- **默认关闭不代表可以删除录屏接入**:RPA / 长任务代码仍须保留 `RpaVideoSession`、`video.add_step` 与 video artifact 写入 `finish()`(见 `RPA.md` §5.3)。
- **已落盘的用户 `.env` 不会被模板整体覆盖**;老用户保留原值,技能升级时仅通过 `merge_missing_env_keys` **追加** `.env.example` 中新增的 key。
## 🚫 红线:敏感信息不进 `.env`
@@ -167,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,但不推荐

View File

@@ -1,6 +1,6 @@
# 用户数据目录与文件路径黄金标准
> 本文是 skill **读写本地文件** 的权威约定。凡涉及下载、导入、导出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
> 本文是 skill **读写本地文件** 的权威约定。凡涉及输入、输出、缓存、RPA 存证等路径,复制模板后须遵守本文;Agent 实现业务 skill 时也应先读本文。
---
@@ -30,11 +30,23 @@ D:\jiangchang-data\12500\download-video-baidu-haokan\
## 2. 标准子目录树
用户可见 · **文件目录**(数据管理「输入 / 输出」文件浏览器只挂这两个):
| 子目录 | 方向 | 用途 | 默认 helper |
|--------|------|------|-------------|
| `input/` | 上行 | 用户/Agent 放入的待处理材料(urls.txt、batch.json、CSV、产品表…) | `get_input_dir()` |
| `output/` | 下行 | 技能/宿主交给用户拿走的结果(视频、回单、xlsx/csv/json…) | `get_output_dir()` |
用户可见 · **HTML 报告页**(宿主扫描挂到技能下,**不是** input/output 文件浏览器):
| 子目录 | 用途 | 默认 helper |
|--------|------|-------------|
| `html/` | 技能写出的静态 `.html` 报告;宿主一层扫描 `*.html`,侧栏预览 / 右键重新生成 | `get_html_dir()` |
内部 / 可选目录(**不上侧栏**):
| 子目录 | 用途 | 默认 helper |
|--------|------|-------------|
| `downloads/` | 从外部拉取的原始文件(视频、回单、PDF…) | `get_downloads_dir()` |
| `imports/` | 用户/Agent 放入的待处理清单(urls.txt、batch.json、CSV) | `get_imports_dir()` |
| `exports/` | 导出给用户的结果(xlsx/csv/json) | `get_exports_dir()` |
| `uploads/` | 待发布到外部平台的 staging(可选) | `get_uploads_dir()` |
| `cache/` | 可再生缓存(页面快照、解析中间结果) | `get_cache_dir()` |
| `temp/` | 单次任务 scratch,任务结束可删 | `get_temp_dir()` |
@@ -45,6 +57,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. 相关文档

View File

@@ -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
@@ -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 | 长任务出现进度 / 可取消;来源标签与触发入口一致 |
@@ -875,6 +929,7 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea
- [ ] 目录名、`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))

View File

@@ -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` 边界 |

View File

@@ -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` |
@@ -65,5 +67,10 @@
| 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 已约束 |

View 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)

View File

@@ -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),不要写进本目录。

View File

@@ -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. 开发注意事项
- 不修改无关项目;不引入旧模板结构

View File

@@ -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()`。

View File

@@ -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:

View File

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

View File

@@ -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)。
---

View File

@@ -22,12 +22,12 @@
必跑套件要像一个紧张的守门员:**快、确定、离线**。典型覆盖:
- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` / `logs` / `log-get` 冒烟;
- CLI:导入 [`cli.app`](../scripts/cli/app.py) 走解析链路、`health`(runtime diagnostics)/ `version` / `init-db` 冒烟;
- 架构守护:无 `scripts/jiangchang_skill_core/`、`platform-kit>=1.2.0` 导入来源、文档/runtime 标准(见 `test_platform_import.py` 等);
- **真实 subprocess**:[`tests/test_entrypoint_subprocess.py`](../tests/test_entrypoint_subprocess.py) 再调用一遍 `python scripts/main.py`,防路径漂移;
- 运行时:`runtime_paths` 与 **`JIANGCHANG_*` 隔离**;
- `SKILL.md` YAML slug vs [`constants.SKILL_SLUG`](../scripts/util/constants.py);
- SQLite 骨架:`task_logs` 创建幂等与仓储读写;
- SQLite 骨架:`init_db` 幂等创建元数据表,默认不建业务表;
- 数据管理元数据:`_jiangchang_*` 幂等初始化、中文展示名、`PRAGMA table_info` 字段顺序、editable 权限(见 `test_display_metadata.py`、`test_data_management_contract.py`);
- Skill Action manifest:`assets/actions.json` 结构、CLI 映射、placements 与 inputSchema 守护(见 `test_actions_manifest.py`);
- 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`);
@@ -35,6 +35,7 @@
- **发布打包守护**:[`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 命名规范校验。
- **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`

View 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

View File

@@ -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、视频留痕 |
## 依赖说明

View File

@@ -12,6 +12,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple
from jiangchang_skill_core import config
from jiangchang_skill_core.activity import interruptible_sleep
from jiangchang_skill_core.rpa import poll_until
from service.browser_session import close_browser_context, get_start_url, start_browser_session
from service.human_verification import (
@@ -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:

View File

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

View File

@@ -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;含可选门户门闩 + 原批量提交流程 |

View File

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

File diff suppressed because it is too large Load Diff

View 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)。

View File

@@ -24,10 +24,10 @@ python {baseDir}/scripts/main.py <command> …
| 类型 | 本技能命令(自填) | Agent |
|------|--------------------|-------|
| **短 CLI 白名单** | (例)`health` / `version` / `logs` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
| **短 CLI 白名单** | (例)`health` / `version` / `render-html` / `<your-read-or-prep>` | 可用 bash/exec + 共享 `python` |
| **Action 强制黑名单** | (例)`<your-long-or-rpa-command>` | **禁止** bash + `process poll`;必须 `run_skill_action` |
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。
细节与 Action 清单见 [`ACTIONS.md`](ACTIONS.md)。快照「重新生成」须有 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
View File

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

View File

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

View File

@@ -1,6 +1,9 @@
# 数据存储与数据管理展示规范
本文是 skill 本地 SQLite 与匠厂宿主「数据管理」页面展示契约的**权威说明**。
**产品定位:** 数据管理既是业务数据与过程的可见面,也是与对话**平级**的 **UI 执行面**(表上 Action 走同一 CLI 闭环)。何时挂执行入口见 [`../development/PRODUCT_SURFACES.md`](../development/PRODUCT_SURFACES.md);本文管表结构与展示元数据。
实现上的 DDL 与默认中文元数据种子见 `scripts/db/display_metadata.py`;`scripts/db/connection.py` 的 `init_db()` 会一并初始化。
## 职责边界
@@ -10,9 +13,11 @@
- 定义真实 SQLite 表结构
- 使用英文 `snake_case` 表名和字段名
- 使用 `_jiangchang_tables` 提供表级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据
- 使用 `_jiangchang_columns` 提供字段级展示元数据(含可选 `description` 表单说明、可选 `grid_help` 表格说明)
- 需要「新增表单预填」时,在物理列上声明 SQLite `DEFAULT`(勿把默认值只写在文案里)
- 定义哪些字段可见、可搜索、可创建、可编辑
- 定义字段展示类型和枚举选项
- (可选)按业务规则维护单元格 / 整行 / 整列 / 区域呈现旁路数据(语义 tone;见 [`CELL_PRESENTATION.md`](CELL_PRESENTATION.md))
- 保证元数据与真实数据库结构一致
- 保证元数据初始化幂等
@@ -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,7 +89,24 @@ metadata:
不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。
若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。
若创建/更新/删除/导入带有领域副作用(自动建目录、加密、级联清理、归一化字段),不要依赖宿主默认直写:在 `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)。
## 标准数据同步语义(外源 → 本地库)
@@ -109,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`。
@@ -129,10 +158,117 @@ metadata:
|------|------|------|
| `table_name` | 是 | 真实英文表名 |
| `display_name` | 是 | 用户可见的中文业务名称 |
| `description` | 否 | 表说明 |
| `description` | 否 | 表说明(表单/备注用途);**不**用于表格上方横幅 |
| `grid_help` | 否 | 数据管理表格上方说明;空/未写则不出横幅。与 `description` 独立,禁止互拷(见下节) |
| `sort_order` | 否 | 兼容保留字段,不用于替代物理字段顺序 |
| `visible` | 是 | `1` 在宿主数据管理中展示 / `0` 隐藏 |
| `readonly` | 是 | `1` 整张表只读;`0` 允许宿主根据字段权限执行写操作 |
| `default_sort_json` | 否 | 数据管理**默认行排序**(见下节);空则宿主用内置默认 |
| `default_visible_columns_json` | 否 | 数据管理**默认展示列**(见下节);空则全显 |
#### 表级默认排序 `default_sort_json`(宿主支持)
技能可声明该表在数据管理中的默认排序;宿主优先采用,未声明或声明无效时回退:
`updated_at DESC` → `created_at DESC` → 主键 DESC。
JSON 数组(可多字段,按顺序):
```json
[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]
```
规则:
- `column` 必须是该表真实列名;敏感列会被宿主忽略
- `direction` 仅 `asc` / `desc`(缺省按 `desc`)
- 用户在 UI 上手动改排序后,以用户选择为准;声明只影响未选择时的默认
- 通过 `upsert_table_metadata(..., default_sort_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(消息时间线):
```python
upsert_table_metadata(
cursor,
table_name="messages",
display_name="消息",
readonly=0,
default_sort_json='[{"column":"sent_at","direction":"desc"},{"column":"id","direction":"desc"}]',
)
```
#### 表级默认展示列 `default_visible_columns_json`(宿主支持)
技能可声明该表在数据管理表格中的**默认展示列**(软默认,不是硬隐藏):
- **已配置**:首次打开(用户尚未改过「列」)只显示名单中的列;其余列仍在「列」面板中,用户可勾回
- **未配置 / 空 / 无效**:显示全部 schema 可见列(与今日行为一致)
- **用户改过「列」或表头「隐藏此列」后**:以本机偏好为准;「恢复默认列布局」后重新回到技能默认(若有)
JSON 数组(物理列名;也可写 `{"column":"col"}` 对象,宿主同样接受):
```json
["stat_type","period","stage","ops_count","timeout_ratio"]
```
规则:
- 列名必须是该表真实列,且 `_jiangchang_columns.visible=1`(硬隐藏列本来就不进表格)
- 与 `visible=0` **不同**:`visible=0` = 列从数据管理 schema /「列」面板移除;本字段只影响默认勾选状态
- 名单为空或全部无效时,宿主 fail-open:全显
- 通过 `upsert_table_metadata(..., default_visible_columns_json=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
示例(时效统计:默认不展示编号与时间戳列):
```python
upsert_table_metadata(
cursor,
table_name="timeliness_stats",
display_name="时效统计",
readonly=1,
default_visible_columns_json=(
'["stat_type","period","stage","ops_count",'
'"timeout_open","timeout_done","timeout_total","timeout_ratio"]'
),
)
```
#### 表格说明 `grid_help`(宿主支持)
技能可给数据管理**表格**写说明,与新增/编辑表单的 `description` **完全独立**:
| 写在哪 | 宿主做什么 | 空/未写 |
|--------|------------|---------|
| `_jiangchang_tables.grid_help` | 表格上方可折叠横幅(首行摘要;有后续行才出展开) | 不出横幅 |
| `_jiangchang_columns.grid_help` | 列头「?」,点击查看该列说明 | 不出「?」 |
规则:
- **opt-in**:大多数表/列不需要写;不写则行为与今日一致
- **禁止**把 `description` 拷进 `grid_help`,也禁止把表格规则写进 `description`(`description` 只给表单灰字)
- 多行文本时:第一行是摘要,其余行点「展开」后可见
- 通过 `upsert_table_metadata(..., grid_help=...)` / `upsert_column_metadata(..., grid_help=...)` 写入;旧库由 `init_display_metadata_tables` 幂等 `ALTER` 补列
- 需已支持该字段的匠厂宿主;更旧版本会忽略未知列,不写则无影响
示例(仅在确有表格规则时写;模板默认业务表不要预填):
```python
upsert_table_metadata(
cursor,
table_name="timeliness_records",
display_name="时效登记",
readonly=1,
grid_help="超时按环节规则判定。\n交单:预计到港日前 3 天须交单",
)
upsert_column_metadata(
cursor,
table_name="timeliness_records",
column_name="submit_date",
display_name="交单日期",
grid_help="预计到港日前 3 天须交单",
editable=0,
)
```
禁止:
@@ -140,6 +276,7 @@ metadata:
- 使用 `_schema_meta` 作为新的正式契约
- 只写英文物理名而不写中文 `display_name`
- 让元数据引用不存在的表
- 用 `visible=0` 假装「默认不展示但用户还能勾出来」(那是硬隐藏,勾不回来)
### `_jiangchang_columns` 字段级标准
@@ -147,17 +284,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` 这一份字段权限声明:
@@ -262,15 +558,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`)。
实现参考:
@@ -278,53 +586,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 参考)
@@ -372,36 +658,77 @@ CREATE TABLE demo_items (
宿主据此渲染选择框;技能侧负责保证 JSON 合法且与物理字段取值一致。
### 2)表关联 lookup(对象,推荐用于外键式字段)
### 2)跨表引用 lookup(对象,用于关联 id 字段)
当字段取值依赖同库另一张业务表时,`display_type` 设为 `lookup`,`options_json` 写对象(**不是**数组):
当字段取值依赖同库另一张业务表时,`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` | 是 | 写入当前字段的值列 |
| `label_column` | 是 | 下拉展示名列 |
| `value_column` | 是 | 写入当前字段的值列(业务 id / 编号) |
| `label_column` | 是 | 选项主文案列(名称) |
| `extra_column` | 否 | 第三展示列(不必叫「备注」);有值时拼进括号 |
| `filter_column` / `filter_value` | 否 | 等值过滤(如只列出 `enabled=1`) |
| `multiple` | 否 | `true` = 多选 Lookup;省略或 `false` = **单选**(默认) |
宿主在加载表 schema 时查询 `ref_table`,把结果展开成 `{value,label}` 下拉选项;**物理列仍存标量**(如 `platform_key` 文本、`account_id` 整数),不做嵌套子表。
宿主在加载表 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`
- 元数据不能残留旧字段(删除或重命名字段时必须同步清理)
- 元数据不能引用不存在的表或字段
- 新增字段或表时必须同步增加元数据
@@ -445,30 +772,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` 物理删除。
## 开发自检
@@ -479,5 +838,5 @@ seed_column_metadata_batch(
## 模板原则
- 模板不做复杂历史迁移框架;新 skill 从当前 schema 起步。
- 元数据 DDL 与 `task_logs` 中文种子以 `scripts/db/display_metadata.py` 为单一权威来源。
- 元数据 DDL 与种子 helper 以 `scripts/db/display_metadata.py` 为单一权威来源。
- 模板定义「如何声明能力」;具体技能定义「声明什么业务能力」;宿主负责「如何通用渲染和执行」。

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -12,8 +12,6 @@ from db.display_metadata import (
DATETIME_UNIX_SECONDS,
METADATA_TABLES,
STANDARD_TIMESTAMP_COLUMNS,
TASK_LOGS_COLUMN_METADATA,
TASK_LOGS_TABLE,
iter_user_tables,
list_editable_business_columns,
list_physical_column_names,
@@ -32,6 +30,7 @@ _ENGLISH_PLACEHOLDER_NAME_PATTERNS = (
)
_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
_CJK_RE = re.compile(r"[\u4e00-\u9fff]")
_FK_DDL_PATTERN = re.compile(r"\bFOREIGN\s+KEY\b|\bREFERENCES\b", re.I)
@dataclass
@@ -220,6 +219,8 @@ def validate_options_json(options_json: str | None) -> tuple[list[str], list[str
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):
@@ -234,6 +235,50 @@ 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 validate_column_permissions(
cursor,
table_name: str,
@@ -414,6 +459,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()
@@ -427,6 +493,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 = ?",
@@ -486,6 +555,9 @@ 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)
cur.execute("SELECT table_name, column_name FROM _jiangchang_columns")
for table_name, column_name in cur.fetchall():
if table_name in METADATA_TABLES:
@@ -516,11 +588,5 @@ def validate_display_metadata(conn: sqlite3.Connection, *, check_column_order: b
def validate_template_database(conn: sqlite3.Connection) -> ValidationReport:
report = validate_display_metadata(conn)
physical = list_physical_column_names(conn.cursor(), TASK_LOGS_TABLE)
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
if physical != expected:
report.errors.append(
f"task_logs 物理字段顺序应为 {expected},当前为 {physical}"
)
return report
"""模板自检入口:校验展示元数据完整性,并扫描业务表 DDL 是否含 SQLite 外键。"""
return validate_display_metadata(conn)

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

@@ -1,6 +1,7 @@
"""任务编排、日志查询模板。
"""任务编排模板。
编排层:job_context / emit / checkpoint / finish、鉴权、任务日志与错误转换。
编排层:job_context / emit / checkpoint / finish、鉴权与错误转换。
过程看统一文件日志;进度/终态看任务中心;业务结果写入业务表(有才建)。
领域业务请放在 ``service/<domain>_service.py``,由本模块调用;多入口(CLI/Action/cron)复用同一内核。
复制后在 cmd_run(或 cmd_sync_*)中接线到真实 domain service,勿按 toolbar/cron/agent 分叉业务。
"""
@@ -11,12 +12,11 @@ import asyncio
import json
import os
import uuid
from typing import Any, Dict, Optional
from typing import Any, Optional
from jiangchang_skill_core import collect_runtime_diagnostics, config, format_runtime_health_lines
from jiangchang_skill_core.activity import JobStopped, emit, finish, job_context
from db import task_logs_repository as tlr
from jiangchang_skill_core.rpa.video_session import RpaVideoSession
from service.entitlement_service import check_entitlement
from service.task_run_support import (
@@ -27,7 +27,6 @@ from service.task_run_support import (
from util.constants import LOG_LOGGER_NAME, PLATFORM_KIT_MIN_VERSION, SKILL_SLUG, SKILL_VERSION
from util.logging_config import get_skill_logger, setup_skill_logging
from util.runtime_paths import get_db_path, get_skill_data_dir, get_skill_root
from util.timeutil import unix_to_iso
from db.connection import init_db
@@ -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 ""

View File

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

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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
@@ -194,7 +201,7 @@ class TestActionsManifest(unittest.TestCase):
if replaces is not None:
self.assertIn(
replaces,
{"create", "update", "delete"},
{"create", "update", "delete", "import"},
msg=f"{action_id} invalid replaces {replaces!r}",
)
bind = action.get("bind") or {}
@@ -242,7 +249,7 @@ class TestActionsManifest(unittest.TestCase):
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"):
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 [])),
@@ -252,9 +259,81 @@ class TestActionsManifest(unittest.TestCase):
self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind")
self.assertEqual(
set(schema["$defs"]["mutationKind"].get("enum") or []),
{"create", "update", "delete"},
{"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
@@ -288,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"]:
@@ -332,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",
@@ -367,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")
@@ -510,8 +718,51 @@ class TestActionsManifest(unittest.TestCase):
"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",

View 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()

View File

@@ -1,5 +1,5 @@
# -*- coding: utf-8 -*-
"""通用 CLI 冒烟:用法、health、version、logs、log-get(不触网、不深测 run 占位)。"""
"""通用 CLI 冒烟:用法、health、version、init-db(不触网、不深测 run 占位)。"""
from __future__ import annotations
import io
@@ -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。"""

View File

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

View File

@@ -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()

View File

@@ -6,7 +6,13 @@ import os
import sqlite3
import unittest
from _support import IsolatedDataRoot, get_skill_root
from _support import (
DEMO_ITEMS_COLUMN_METADATA,
DEMO_ITEMS_TABLE,
IsolatedDataRoot,
get_skill_root,
seed_demo_items,
)
class TestDisplayMetadata(unittest.TestCase):
@@ -20,14 +26,18 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 1)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns WHERE table_name = 'task_logs'")
self.assertEqual(int(cur.fetchone()[0]), 10)
cur.execute("SELECT COUNT(*) FROM _jiangchang_tables")
self.assertEqual(int(cur.fetchone()[0]), 0)
cur.execute("SELECT COUNT(*) FROM _jiangchang_columns")
self.assertEqual(int(cur.fetchone()[0]), 0)
cur.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='task_logs'"
)
self.assertIsNone(cur.fetchone())
finally:
conn.close()
def test_task_logs_has_chinese_table_and_column_names(self) -> None:
def test_demo_items_fixture_has_chinese_names(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
@@ -35,32 +45,35 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = 'task_logs'"
"SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = ?",
(DEMO_ITEMS_TABLE,),
)
display_name, readonly = cur.fetchone()
self.assertEqual(display_name, "任务日志")
self.assertEqual(int(readonly), 1)
self.assertEqual(display_name, "示例记录")
self.assertEqual(int(readonly), 0)
cur.execute(
"""
SELECT column_name, display_name, editable
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
WHERE table_name = ?
ORDER BY column_name
"""
""",
(DEMO_ITEMS_TABLE,),
)
by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()}
self.assertEqual(by_name["status"][0], "状态")
self.assertEqual(int(by_name["status"][1]), 0)
self.assertEqual(by_name["error_msg"][0], "错误信息")
self.assertEqual(int(by_name["error_msg"][1]), 0)
self.assertEqual(int(by_name["status"][1]), 1)
self.assertEqual(by_name["title"][0], "标题")
self.assertEqual(by_name["created_at"][0], "创建时间")
self.assertEqual(int(by_name["created_at"][1]), 0)
finally:
conn.close()
def test_task_logs_not_manual_create_in_host_ui(self) -> None:
def test_readonly_table_not_manual_create_in_host_ui(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import table_allows_manual_create
@@ -68,33 +81,34 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
self.assertFalse(table_allows_manual_create(conn.cursor(), "task_logs"))
cur = conn.cursor()
seed_demo_items(cur, readonly=1)
conn.commit()
self.assertFalse(table_allows_manual_create(cur, DEMO_ITEMS_TABLE))
finally:
conn.close()
def test_task_logs_physical_order_matches_pragma_cid(self) -> None:
def test_demo_items_physical_order_matches_pragma_cid(self) -> None:
with IsolatedDataRoot():
from db.connection import init_db
from db.display_metadata import TASK_LOGS_COLUMN_METADATA, list_physical_column_names
from db.connection import get_conn
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
init_db()
conn = get_conn()
try:
cur = conn.cursor()
physical = list_physical_column_names(cur, "task_logs")
expected = [str(item["column_name"]) for item in TASK_LOGS_COLUMN_METADATA]
self.assertEqual(
physical,
expected,
)
cur.execute("PRAGMA table_info('task_logs')")
seed_demo_items(cur)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
expected = [str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA]
self.assertEqual(physical, expected)
cur.execute("PRAGMA table_info('demo_items')")
cid_order = [row[1] for row in sorted(cur.fetchall(), key=lambda row: int(row[0]))]
self.assertEqual(cid_order, expected)
finally:
conn.close()
def test_init_db_sets_task_logs_display_order_null(self) -> None:
def test_seed_sets_display_order_null(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
@@ -102,12 +116,15 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT column_name, display_order
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
"""
WHERE table_name = ?
""",
(DEMO_ITEMS_TABLE,),
)
for column_name, display_order in cur.fetchall():
self.assertIsNone(
@@ -117,33 +134,32 @@ class TestDisplayMetadata(unittest.TestCase):
finally:
conn.close()
def test_reinit_clears_stale_display_order(self) -> None:
def test_reseed_clears_stale_display_order(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
finally:
conn.close()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT display_order FROM _jiangchang_columns
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
self.assertIsNone(cur.fetchone()[0])
finally:
@@ -158,22 +174,26 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 1
WHERE table_name = 'task_logs' AND column_name = 'updated_at'
"""
WHERE table_name = ? AND column_name = 'updated_at'
""",
(DEMO_ITEMS_TABLE,),
)
cur.execute(
"""
UPDATE _jiangchang_columns
SET display_order = 99
WHERE table_name = 'task_logs' AND column_name = 'id'
"""
WHERE table_name = ? AND column_name = 'id'
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
physical = list_physical_column_names(cur, "task_logs")
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
self.assertEqual(physical[0], "id")
self.assertEqual(physical[-1], "updated_at")
finally:
@@ -188,7 +208,7 @@ class TestDisplayMetadata(unittest.TestCase):
conn = get_conn()
try:
tables = list(iter_user_tables(conn.cursor()))
self.assertEqual(tables, ["task_logs"])
self.assertEqual(tables, [])
finally:
conn.close()
@@ -205,6 +225,37 @@ class TestDisplayMetadata(unittest.TestCase):
finally:
conn.close()
def test_validate_rejects_sqlite_foreign_keys_on_business_tables(self) -> None:
from db.display_metadata import (
_JIANGCHANG_COLUMNS_DDL,
_JIANGCHANG_TABLES_DDL,
)
from db.display_metadata_validator import validate_display_metadata
conn = sqlite3.connect(":memory:")
try:
conn.execute(_JIANGCHANG_TABLES_DDL)
conn.execute(_JIANGCHANG_COLUMNS_DDL)
conn.execute("CREATE TABLE parent_rows (id INTEGER PRIMARY KEY, title TEXT)")
conn.execute(
"""
CREATE TABLE child_rows (
id INTEGER PRIMARY KEY,
parent_id INTEGER NOT NULL,
FOREIGN KEY (parent_id) REFERENCES parent_rows(id)
)
"""
)
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(
any("FOREIGN KEY" in err or "REFERENCES" in err for err in report.errors),
msg=f"expected FK error, got: {report.errors}",
)
self.assertTrue(any("child_rows" in err for err in report.errors))
finally:
conn.close()
def test_validate_missing_both_metadata_tables(self) -> None:
conn = sqlite3.connect(":memory:")
try:
@@ -271,13 +322,15 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"DELETE FROM _jiangchang_columns WHERE table_name = 'task_logs' AND column_name = 'status'"
"DELETE FROM _jiangchang_columns WHERE table_name = ? AND column_name = 'status'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
self.assertFalse(report.ok)
self.assertTrue(any("task_logs.status" in err for err in report.errors))
self.assertTrue(any("demo_items.status" in err for err in report.errors))
finally:
conn.close()
@@ -289,12 +342,14 @@ class TestDisplayMetadata(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"""
INSERT INTO _jiangchang_columns
(table_name, column_name, display_name, visible, searchable, editable)
VALUES ('task_logs', 'not_exists', '不存在', 1, 1, 1)
"""
VALUES (?, 'not_exists', '不存在', 1, 1, 1)
""",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_display_metadata(conn)
@@ -498,15 +553,258 @@ 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)
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__":
unittest.main()

View File

@@ -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:

View 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()

View File

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

View File

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

View File

@@ -50,6 +50,26 @@ class TestSkillMetadata(unittest.TestCase):
self.assertEqual(SKILL_SLUG, skill_dir)
self.assertEqual(slug_md, skill_dir)
def test_skill_version_alignment(self) -> None:
"""SKILL.md version 必须与 constants.SKILL_VERSION 一致(POLICY-RELEASE-001 日常侧)。"""
root = get_skill_root()
md_path = os.path.join(root, "SKILL.md")
with open(md_path, encoding="utf-8") as f:
md = f.read()
match = re.search(r"(?m)^version:\s*[\"']?([0-9]+\.[0-9]+\.[0-9]+)[\"']?\s*$", md)
self.assertIsNotNone(match, "SKILL.md must declare top-level semver version")
assert match is not None
md_version = match.group(1)
from util.constants import SKILL_VERSION
self.assertEqual(
md_version,
SKILL_VERSION,
"SKILL.md version 必须与 constants.SKILL_VERSION 一致(发布前再与 git tag 对齐)",
)
self.assertRegex(SKILL_VERSION, r"^\d+\.\d+\.\d+$")
if __name__ == "__main__":
unittest.main()

View File

@@ -34,7 +34,7 @@ FORBIDDEN_PHRASES = (
POSITIVE_MARKERS = (
"jiangchang-platform-kit",
"1.2.2",
"1.2.11",
"共享 runtime",
)

View File

@@ -6,11 +6,11 @@ import sqlite3
import time
import unittest
from _support import IsolatedDataRoot
from _support import DEMO_ITEMS_TABLE, IsolatedDataRoot, seed_demo_items
class TestTimestampColumns(unittest.TestCase):
def test_init_db_creates_integer_timestamp_columns_with_defaults(self) -> None:
def test_seed_creates_integer_timestamp_columns_with_defaults(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.display_metadata import list_physical_column_names
@@ -20,10 +20,12 @@ class TestTimestampColumns(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
physical = list_physical_column_names(cur, "task_logs")
seed_demo_items(cur)
conn.commit()
physical = list_physical_column_names(cur, DEMO_ITEMS_TABLE)
self.assertEqual(physical[-2:], ["created_at", "updated_at"])
info = _table_column_info(cur, "task_logs")
info = _table_column_info(cur, DEMO_ITEMS_TABLE)
for column_name in ("created_at", "updated_at"):
column = info[column_name]
self.assertIn("INT", str(column["type"]).upper())
@@ -39,13 +41,14 @@ class TestTimestampColumns(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
before = int(time.time())
cur.execute(
"INSERT INTO task_logs (task_type, status) VALUES ('auto-ts', 'success')"
"INSERT INTO demo_items (title, status) VALUES ('auto-ts', 'draft')"
)
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM task_logs ORDER BY id DESC LIMIT 1"
"SELECT created_at, updated_at FROM demo_items ORDER BY id DESC LIMIT 1"
)
created_at, updated_at = cur.fetchone()
after = int(time.time())
@@ -65,21 +68,22 @@ class TestTimestampColumns(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
cur.execute(
"""
INSERT INTO task_logs (task_type, status, created_at, updated_at)
VALUES ('update-ts', 'success', 1000, 1000)
INSERT INTO demo_items (title, status, created_at, updated_at)
VALUES ('update-ts', 'draft', 1000, 1000)
"""
)
log_id = int(cur.lastrowid)
row_id = int(cur.lastrowid)
conn.commit()
cur.execute("UPDATE task_logs SET status = 'failed' WHERE id = ?", (log_id,))
cur.execute("UPDATE demo_items SET status = 'published' WHERE id = ?", (row_id,))
conn.commit()
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE id = ?",
(log_id,),
"SELECT created_at, updated_at FROM demo_items WHERE id = ?",
(row_id,),
)
created_at, updated_at = cur.fetchone()
self.assertEqual(created_at, 1000)
@@ -95,10 +99,11 @@ class TestTimestampColumns(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
cur.execute(
"""
INSERT INTO task_logs (task_type, status, created_at, updated_at)
VALUES ('stable-ts', 'success', 1234567890, 1234567891)
INSERT INTO demo_items (title, status, created_at, updated_at)
VALUES ('stable-ts', 'draft', 1234567890, 1234567891)
"""
)
conn.commit()
@@ -110,7 +115,7 @@ class TestTimestampColumns(unittest.TestCase):
try:
cur = conn.cursor()
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE task_type = 'stable-ts'"
"SELECT created_at, updated_at FROM demo_items WHERE title = 'stable-ts'"
)
self.assertEqual(cur.fetchone(), (1234567890, 1234567891))
finally:
@@ -125,13 +130,16 @@ class TestTimestampColumns(unittest.TestCase):
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT column_name, display_name, display_type, editable, display_order
FROM _jiangchang_columns
WHERE table_name = 'task_logs'
WHERE table_name = ?
AND column_name IN ('created_at', 'updated_at')
"""
""",
(DEMO_ITEMS_TABLE,),
)
by_name = {
name: (display_name, display_type, editable, display_order)
@@ -153,37 +161,43 @@ class TestTimestampColumns(unittest.TestCase):
from db.connection import get_conn, init_db
from db.display_metadata import DATETIME_UNIX_SECONDS
init_db()
init_db()
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
seed_demo_items(cur)
conn.commit()
cur.execute(
"""
SELECT display_type FROM _jiangchang_columns
WHERE table_name = 'task_logs' AND column_name = 'created_at'
"""
WHERE table_name = ? AND column_name = 'created_at'
""",
(DEMO_ITEMS_TABLE,),
)
self.assertEqual(cur.fetchone()[0], DATETIME_UNIX_SECONDS)
finally:
conn.close()
def test_repository_insert_uses_database_timestamps(self) -> None:
def test_insert_uses_database_timestamps(self) -> None:
with IsolatedDataRoot():
from db import task_logs_repository as tlr
from db.connection import get_conn, init_db
init_db()
before = int(time.time())
new_id = tlr.save_task_log(task_type="repo-ts", status="success")
after = int(time.time())
conn = get_conn()
try:
cur = conn.cursor()
seed_demo_items(cur)
before = int(time.time())
cur.execute(
"SELECT created_at, updated_at FROM task_logs WHERE id = ?",
(new_id,),
"INSERT INTO demo_items (title, status) VALUES ('repo-ts', 'draft')"
)
row_id = int(cur.lastrowid)
conn.commit()
after = int(time.time())
cur.execute(
"SELECT created_at, updated_at FROM demo_items WHERE id = ?",
(row_id,),
)
created_at, updated_at = cur.fetchone()
self.assertGreaterEqual(int(created_at), before)
@@ -200,8 +214,11 @@ class TestTimestampColumns(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"UPDATE _jiangchang_columns SET display_type = NULL WHERE table_name = 'task_logs' AND column_name = 'created_at'"
"UPDATE _jiangchang_columns SET display_type = NULL "
"WHERE table_name = ? AND column_name = 'created_at'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_template_database(conn)
@@ -260,8 +277,11 @@ class TestTimestampColumns(unittest.TestCase):
init_db()
conn = get_conn()
try:
seed_demo_items(conn.cursor())
conn.execute(
"UPDATE _jiangchang_columns SET editable = 1 WHERE table_name = 'task_logs' AND column_name = 'updated_at'"
"UPDATE _jiangchang_columns SET editable = 1 "
"WHERE table_name = ? AND column_name = 'updated_at'",
(DEMO_ITEMS_TABLE,),
)
conn.commit()
report = validate_template_database(conn)
@@ -315,17 +335,19 @@ class TestTimestampColumns(unittest.TestCase):
def test_trigger_creation_is_idempotent(self) -> None:
with IsolatedDataRoot():
from db.connection import get_conn, init_db
from db.timestamp_columns import TASK_LOGS_UPDATED_AT_TRIGGER, has_updated_at_trigger
from db.timestamp_columns import has_updated_at_trigger, updated_at_trigger_name
init_db()
init_db()
conn = get_conn()
try:
self.assertTrue(has_updated_at_trigger(conn.cursor(), "task_logs"))
cur = conn.cursor()
seed_demo_items(cur)
seed_demo_items(cur)
conn.commit()
self.assertTrue(has_updated_at_trigger(cur, DEMO_ITEMS_TABLE))
cur.execute(
"SELECT COUNT(*) FROM sqlite_master WHERE type = 'trigger' AND name = ?",
(TASK_LOGS_UPDATED_AT_TRIGGER,),
(updated_at_trigger_name(DEMO_ITEMS_TABLE),),
)
self.assertEqual(int(cur.fetchone()[0]), 1)
finally:

View File

@@ -0,0 +1,221 @@
# -*- coding: utf-8 -*-
"""用户市场文档契约:README≠教程、TUTORIAL 三入口结构、与 actions.json 对齐。
契约全文:development/TUTORIAL_SURFACES.md
- 模板占位 slug(your-skill-slug):只检查骨架与契约文档存在
- 业务技能:按 actions.json placements 硬校验教程章节与 README 反模式
"""
from __future__ import annotations
import json
import os
import re
import unittest
from _support import get_skill_root
from util.constants import SKILL_SLUG
TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug"
_SECTION_DATA_MGMT = re.compile(r"(?m)^##\s*.*数据管理")
_SECTION_DIALOG = re.compile(r"(?m)^##\s*.*(对话任务|对话)")
_SECTION_CRON = re.compile(r"(?m)^##\s*.*定时任务")
_SECTION_COMPARE = re.compile(r"(?m)^##\s*.*(对照表|主能力)")
_SECTION_STUCK = re.compile(r"(?m)^##\s*.*(常见卡住|卡住怎么办)")
_SECTION_WHAT = re.compile(r"(?m)^##\s*.*(本技能做什么|做什么)")
_NO_CRON_HINT = re.compile(r"不需要定时|未提供定时|不提供定时|不适用.*定时|定时.*不适用")
# README 反模式:分步操作 / Agent 话术专章(说明 Tab 禁止)
_README_HOWTO_AGENT = re.compile(r"(?m)^##\s*.*告诉\s*Agent")
_README_ENTRY_CHAPTER = re.compile(r"(?m)^##\s*入口[一二三]")
_README_NUMBERED_DM_PATH = re.compile(
r"(?m)^\d+[\.、]\s*.*(数据管理|选品任务|表顶).{0,40}(点击|点\s*「)"
)
def _read(rel: str) -> str:
path = os.path.join(get_skill_root(), rel)
with open(path, encoding="utf-8") as f:
return f.read()
def _load_actions() -> list[dict]:
path = os.path.join(get_skill_root(), "assets", "actions.json")
if not os.path.isfile(path):
return []
with open(path, encoding="utf-8") as f:
data = json.load(f)
return list(data.get("actions") or [])
def _placement_sets(actions: list[dict]) -> tuple[bool, bool, bool]:
has_dm = False
has_agent = False
has_cron = False
dm_tokens = frozenset({"toolbar", "row", "batch"})
for action in actions:
placements = set(action.get("placements") or [])
if placements & dm_tokens:
has_dm = True
if "agent" in placements and str(action.get("id") or "") not in (
"health",
"version",
"config-path",
):
has_agent = True
if "cron" in placements:
has_cron = True
return has_dm, has_agent, has_cron
class TestTutorialSurfacesContract(unittest.TestCase):
def test_tutorial_surfaces_doc_exists(self) -> None:
path = os.path.join(get_skill_root(), "development", "TUTORIAL_SURFACES.md")
self.assertTrue(
os.path.isfile(path),
msg="development/TUTORIAL_SURFACES.md must exist (user market doc contract)",
)
text = _read("development/TUTORIAL_SURFACES.md")
for marker in (
"README.md",
"TUTORIAL.md",
"数据管理",
"对话",
"定时",
"skill-detail",
"对照表",
"正文不要用",
"禁止",
):
self.assertIn(marker, text, msg=f"TUTORIAL_SURFACES.md missing {marker!r}")
def test_market_docs_forbid_redundant_page_h1(self) -> None:
"""宿主已有技能名与 Tab 标题:说明/教程正文禁止再用 # 一级标题。"""
def strip_fences(src: str) -> str:
return re.sub(r"```.*?```", "", src, flags=re.S)
for rel in ("README.md", "TUTORIAL.md"):
body = strip_fences(_read(rel))
# YAML frontmatter 里不应出现 ATX H1;正文亦禁止 #
self.assertIsNone(
re.search(r"(?m)^#\s+\S", body),
msg=(
f"{rel} must not use ATX H1 (# …); host already shows skill name / tab. "
"Use ## for sections (see TUTORIAL_SURFACES.md §0.1)."
),
)
self.assertIsNone(
re.search(r"(?m)^#\s+.·\s*使用教程\s*$", body),
msg=f"{rel} must not start sections with '# … · 使用教程'",
)
def test_development_md_points_to_tutorial_surfaces(self) -> None:
text = _read("development/DEVELOPMENT.md")
self.assertIn(
"TUTORIAL_SURFACES.md",
text,
msg="DEVELOPMENT.md must reference TUTORIAL_SURFACES.md for market docs",
)
def test_template_or_business_tutorial_has_required_skeleton(self) -> None:
tutorial = _read("TUTORIAL.md")
self.assertRegex(
tutorial,
_SECTION_WHAT,
msg="TUTORIAL.md must have a '本技能做什么' (or equivalent) section",
)
self.assertRegex(
tutorial,
_SECTION_DATA_MGMT,
msg="TUTORIAL.md must have a 数据管理 section (or 不适用 placeholder)",
)
self.assertRegex(
tutorial,
_SECTION_DIALOG,
msg="TUTORIAL.md must have a 对话 section (or 不适用 placeholder)",
)
self.assertRegex(
tutorial,
_SECTION_CRON,
msg="TUTORIAL.md must have a 定时任务 section (teach or N/A)",
)
self.assertRegex(
tutorial,
_SECTION_COMPARE,
msg="TUTORIAL.md must have a 主能力对照表 section",
)
self.assertRegex(
tutorial,
_SECTION_STUCK,
msg="TUTORIAL.md must have a 常见卡住 section",
)
# 禁止旧「对话单线」作为唯一主结构标题
self.assertNotRegex(
tutorial,
r"(?m)^##\s*第一步:打开技能并说清目标\s*$",
msg="TUTORIAL.md must not use legacy dialogue-only first step as sole structure",
)
def test_readme_has_no_legacy_agent_howto_chapter(self) -> None:
readme = _read("README.md")
self.assertIsNone(
_README_HOWTO_AGENT.search(readme),
msg="README.md must not have '告诉 Agent' howto chapter (belongs in TUTORIAL)",
)
self.assertIsNone(
_README_ENTRY_CHAPTER.search(readme),
msg="README.md must not have 入口一/二/三 chapters (belongs in TUTORIAL)",
)
def test_business_skill_tutorial_aligns_with_actions(self) -> None:
if SKILL_SLUG == TEMPLATE_PLACEHOLDER_SLUG:
self.skipTest("template placeholder slug: skip actions alignment hard checks")
actions = _load_actions()
has_dm, has_agent, has_cron = _placement_sets(actions)
tutorial = _read("TUTORIAL.md")
readme = _read("README.md")
if has_dm:
self.assertRegex(
tutorial,
_SECTION_DATA_MGMT,
msg="business skill with data-management placements must document 数据管理 in TUTORIAL",
)
# 准备类若挂了 toolbar,教程不应把 skill-detail 当唯一主路径
self.assertNotRegex(
tutorial,
r"(?m)^\d+[\.、]\s*.*技能详情.{0,30}点击",
msg="TUTORIAL must not use 技能详情点击 as primary numbered path when DM exists",
)
if has_agent:
self.assertRegex(
tutorial,
_SECTION_DIALOG,
msg="business skill with agent placements must document 对话 in TUTORIAL",
)
if has_cron:
self.assertRegex(
tutorial,
_SECTION_CRON,
msg="with cron placements, TUTORIAL must have 定时任务 section",
)
else:
self.assertRegex(
tutorial,
_NO_CRON_HINT,
msg="without cron placements, TUTORIAL must declare 不需要/未提供定时",
)
# README 不应出现「数据管理 → … → 点击」式编号步骤
self.assertIsNone(
_README_NUMBERED_DM_PATH.search(readme),
msg="README.md must not contain numbered data-management click steps",
)
if __name__ == "__main__":
unittest.main()

View File

@@ -1,9 +1,8 @@
# -*- coding: utf-8 -*-
"""RpaVideoSession 模板示范与 result_summary / CLI 输出测试。"""
"""RpaVideoSession 模板示范与 finish() video 字段 / CLI 输出测试。"""
from __future__ import annotations
import io
import json
import os
import re
import unittest
@@ -131,7 +130,7 @@ class TestPrintVideoSummary(unittest.TestCase):
class TestMergeVideoIntoResultSummary(unittest.TestCase):
def test_result_summary_includes_video_fields(self) -> None:
def test_finish_payload_includes_video_fields(self) -> None:
from service.task_run_support import merge_video_into_result_summary
video_info = {
@@ -202,9 +201,17 @@ class TestTemplateRunVideoSession(unittest.TestCase):
return None
with patch.object(task_service, "RpaVideoSession", _CapturingFakeSession):
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="demo-target", input_id="42")
with patch.object(task_service, "finish", side_effect=_capture_finish):
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="demo-target", input_id="42")
self.assertEqual(rc, 1)
self.assertTrue(created, "RpaVideoSession should be constructed")
@@ -220,39 +227,39 @@ class TestTemplateRunVideoSession(unittest.TestCase):
out = buf.getvalue()
self.assertIn("模板", out)
from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
summary = json.loads(rows[0][7])
self.assertTrue(finish_kwargs, "finish() should be called")
summary = finish_kwargs[-1]
self.assertIn("video", summary)
self.assertIn("video_path", summary)
class TestCmdRunTaskLogCoverage(unittest.TestCase):
def test_cmd_run_entitlement_failure_writes_task_log(self) -> None:
class TestCmdRunFinishCoverage(unittest.TestCase):
def test_cmd_run_entitlement_failure_calls_finish(self) -> None:
with IsolatedDataRoot(user_id="_entitlement_fail"):
from service import task_service
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
with patch.object(task_service, "check_entitlement", return_value=(False, "mock denied")):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t1", input_id="99")
with patch.object(task_service, "finish", side_effect=_capture_finish):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t1", input_id="99")
self.assertEqual(rc, 1)
from db import task_logs_repository as tlr
self.assertTrue(finish_kwargs)
payload = finish_kwargs[-1]
self.assertEqual(payload.get("status"), "failed")
self.assertEqual(payload.get("error_code"), "ENTITLEMENT_DENIED")
self.assertEqual(payload.get("stage"), "auth")
self.assertIn("mock denied", payload.get("message") or "")
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
_rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0]
self.assertEqual(status, "failed")
self.assertEqual(tid, "t1")
self.assertEqual(iid, "99")
self.assertIn("mock denied", err or "")
summary = json.loads(rsum or "{}")
self.assertEqual(summary.get("stage"), "auth")
def test_cmd_run_exception_writes_task_log(self) -> None:
def test_cmd_run_exception_calls_finish(self) -> None:
with IsolatedDataRoot(user_id="_exception_run"):
os.environ["OPENCLAW_RECORD_VIDEO"] = "0"
config.reset_cache()
@@ -262,25 +269,26 @@ class TestCmdRunTaskLogCoverage(unittest.TestCase):
async def _boom(*_args, **_kwargs):
raise RuntimeError("boom")
finish_kwargs: list[dict] = []
original_finish = task_service.finish
def _capture_finish(*args, **kwargs):
finish_kwargs.append(kwargs)
return original_finish(*args, **kwargs)
with patch.object(task_service, "check_entitlement", return_value=(True, "")):
with patch.object(task_service, "_run_template_demo", side_effect=_boom):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t2", input_id="88")
with patch.object(task_service, "finish", side_effect=_capture_finish):
buf = io.StringIO()
with redirect_stdout(buf), redirect_stderr(io.StringIO()):
rc = task_service.cmd_run(target="t2", input_id="88")
self.assertEqual(rc, 1)
from db import task_logs_repository as tlr
rows = tlr.list_task_logs(1)
self.assertTrue(rows)
_rid, _ttype, tid, iid, _ititle, status, err, rsum, _cat, _uat = rows[0]
self.assertEqual(status, "failed")
self.assertEqual(tid, "t2")
self.assertEqual(iid, "88")
self.assertIn("任务执行异常", err or "")
summary = json.loads(rsum or "{}")
self.assertEqual(summary.get("stage"), "run")
self.assertEqual(summary.get("error"), "unexpected_exception")
self.assertTrue(finish_kwargs)
payload = finish_kwargs[-1]
self.assertEqual(payload.get("status"), "failed")
self.assertEqual(payload.get("stage"), "run")
self.assertIn("任务执行异常", payload.get("message") or "")
def test_get_task_logger_fallback_when_not_initialized(self) -> None:
from service import task_service

View File

@@ -1,5 +1,15 @@
# skill-template 工具
## UI 拾取(开发期)
`tools/ui_inspector` 已迁出本模板仓。请使用独立产品仓:
- Gitea:https://git.jc2009.com/client-jiangchang/jiangchang-ui-recorder
- 本机常见路径:`D:\OpenClaw\client-jiangchang\jiangchang-ui-recorder`
- 启动:`python run.py`
模板仓历史中仍保留删除前的归档提交(可按需从 Git 恢复旧副本);日常开发与运行以独立仓为准。
## 业务仓也可以直接 clone(来源 A)
若项目经理已在**公司自建 Gitea**([https://git.jc2009.com/](https://git.jc2009.com/),基于开源 Gitea 自建,**不是** Gitea 官网)开好业务技能仓库并授权,技术人员应直接: