diff --git a/CHANGELOG.md b/CHANGELOG.md index 92c7322..edd8866 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 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 零配置 diff --git a/SKILL.md b/SKILL.md index 810746a..b85631e 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.73 +version: 1.0.74 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json index 38de9e1..48032aa 100644 --- a/assets/schemas/skill-actions.schema.json +++ b/assets/schemas/skill-actions.schema.json @@ -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)。", "type": "object", "required": ["schemaVersion", "skill", "actions"], "additionalProperties": false, @@ -183,6 +183,75 @@ }, "description": "表级绑定;toolbar 必填。可选 inputMapping / columnMapping。concurrency/locks 勿写入。" }, + "hostEffectId": { + "type": "string", + "enum": ["reloadTable", "refreshCatalog", "clearSelection"], + "description": "数据管理 UI 副作用(匠厂宿主 ≥2.8.5)。reloadTable=刷新当前表(含 Data Policy);refreshCatalog=刷新技能目录;clearSelection=清空多选。" + }, + "hostEffects": { + "type": "object", + "additionalProperties": false, + "properties": { + "onSuccess": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 退出 0,或 async 任务已被任务中心接收时触发(不是 Job 跑完)。" + }, + "onFailure": { + "type": "array", + "items": { "$ref": "#/$defs/hostEffectId" }, + "uniqueItems": true, + "description": "sync 失败,或 async 启动被拒时触发。" + } + }, + "description": "可选。UI 副作用简写;省略则宿主不额外改 UI。与 placements/executionProfile/interaction 正交;cron/agent 忽略。新技能有编排需求时优先用 steps。" + }, + "actionStep": { + "type": "object", + "required": ["when", "do"], + "additionalProperties": false, + "properties": { + "when": { + "type": "string", + "enum": ["before", "afterSuccess", "afterFailure"], + "description": "before=CLI 前;afterSuccess/afterFailure 时机同 hostEffects.onSuccess/onFailure。" + }, + "do": { + "type": "string", + "enum": ["deleteByFk", "deleteRows", "reloadTable", "refreshCatalog", "clearSelection"], + "description": "闭环操作或 UI 效果。deleteByFk/deleteRows 须配合 table(及 fk)。" + }, + "table": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk / deleteRows 目标表(snake_case)。" + }, + "fk": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z][a-z0-9_]*$", + "description": "deleteByFk 时子表外键列名。" + } + }, + "allOf": [ + { + "if": { + "properties": { "do": { "const": "deleteByFk" } }, + "required": ["do"] + }, + "then": { "required": ["table", "fk"] } + }, + { + "if": { + "properties": { "do": { "const": "deleteRows" } }, + "required": ["do"] + }, + "then": { "required": ["table"] } + } + ] + }, "action": { "type": "object", "required": ["id", "label", "description", "placements", "entrypoint", "executionProfile"], @@ -238,6 +307,13 @@ "description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。" }, "bind": { "$ref": "#/$defs/bind" }, + "hostEffects": { "$ref": "#/$defs/hostEffects" }, + "steps": { + "type": "array", + "maxItems": 8, + "items": { "$ref": "#/$defs/actionStep" }, + "description": "可选。有序宿主编排(before / afterSuccess / afterFailure);单次最多 8 步。与 hostEffects 可并存(同效果可能各跑一次)。新技能优先用 steps。省略则无额外步骤。需匠厂宿主 ≥2.8.5。" + }, "authz": { "type": "object", "additionalProperties": false, diff --git a/development/SKILL_ACTION_RUNTIME.md b/development/SKILL_ACTION_RUNTIME.md index aa85f6d..3863c62 100644 --- a/development/SKILL_ACTION_RUNTIME.md +++ b/development/SKILL_ACTION_RUNTIME.md @@ -19,9 +19,8 @@ 1. **宿主管调度,技能管业务** — 不自行实现任务中心 / Job 轮询 / 后台守护进程。 2. **单业务内核、多入口** — 数据管理、定时任务、Agent、技能详情与直接 CLI 复用同一 domain service;禁止按入口复制业务逻辑。 -3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。 -4. **长耗时建议 async** — 开浏览器、RPA、HTTP 大文件下载等建议 `executionProfile: "async"`,进**任务中心**;这是对**方法**的选择,**不**限制可以出现在哪些 placement / 入口。 -5. **Agent:短 CLI / 长 Action** — 短查询可用共享 Python bash CLI;长任务 / RPA **禁止** `exec` + `process poll`,必须 `run_skill_action`。 +3. **五职责正交** — `placements` / `bind.tables` / `executionProfile` / `entrypoint` / domain service 各管一事(见 ACTIONS.md)。可选第六轴 **policy**(见 `references/POLICY.md`):未启用 = L0 与今日一致。可选 **`hostEffects` / `steps`**(见 ACTIONS.md):数据管理交互前后的 UI/级联编排;省略 = 不额外改 UI(匠厂宿主 ≥2.8.5)。 +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 流**。 7. **显式声明 `executionProfile`** — 心智默认 sync、例外才 async;每个 action **必须**写明 `sync` 或 `async`;宿主省略时按 sync,新技能仍禁止赌缺省;禁止第三种模糊模式(`auto` / `background` / `deferred` 等)。 diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 93aeec7..27e393d 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -85,6 +85,7 @@ Action 是**可选能力**: | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 | | **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | +| **`hostEffects` / `steps`** | **可选**:数据管理交互跑完前后,宿主要不要刷新表 / 清选区 / 级联删等 | 不决定业务写什么;**省略 = 宿主不额外改 UI**(≥2.8.5 起不再硬编码 mutation 后刷新);cron/agent 忽略 | | **`policy` / Snapshot** | **可选**:当前身份在数据管理下允许哪些 Action / 宿主内置能力 / 行列范围 | 不替代 placements;不替代 domain 写路径鉴权;多数技能不配 = L0 全开 | | **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | | **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | @@ -96,7 +97,7 @@ Action 是**可选能力**: | 层级 | 稳定字段 / 能力 | |------|----------------| | 顶层 | `schemaVersion`、`skill`、`actions`;可选 **`policy`**(Data Policy 启用块) | -| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`authz`**(仅文档提示) | +| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`**、**`hostEffects`**、**`steps`**、**`authz`**(仅文档提示) | | entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | | placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 | | **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) | @@ -107,6 +108,8 @@ Action 是**可选能力**: | **executionProfile** | `"sync"` \| `"async"`(**必须显式写出**,禁止依赖缺省) | | **interaction** | `"auto"` \| `"direct"` \| `"confirm"` \| `"form"`(可选;省略≈`auto`) | | **interactionByPlacement** | 可选;按 placement 覆盖为 `direct`/`confirm`/`form`(不可写 `auto`) | +| **hostEffects** | 可选;`onSuccess` / `onFailure` → `reloadTable` \| `refreshCatalog` \| `clearSelection`(匠厂宿主 ≥2.8.5) | +| **steps** | 可选;有序 `{ when, do, table?, fk? }`,`when`=`before`\|`afterSuccess`\|`afterFailure`;新技能优先(≥2.8.5) | ## `bind.tables`(表级绑定) @@ -248,6 +251,84 @@ Action 是**可选能力**: - **cron 触发 / agent**:忽略 `interaction`(无人值守 / 模型填参);`confirmation` 对 agent 仍作 `requiresConfirmation` 元数据。 - 不要用 `interaction: "direct"` 掩盖需要用户填写的可编辑字段——宿主在 `auto` 下会因可编辑参数回落到 `form`;显式 `direct` + 可编辑字段时参数取 default/预填,作者须自觉。 +## `hostEffects` / `steps`(执行前后宿主编排,≥2.8.5) + +与 `placements` / `executionProfile` / `interaction` **正交**。决定的是:数据管理里点完 Action 之后(或之前),宿主要不要**刷新当前表、刷新目录、清空多选、级联删子表**等。 + +- **省略 / 空** ⇒ 宿主**不**额外改 UI(≥2.8.5 起不再对 mutation 硬编码刷新)。本地库若已由技能写新,界面仍可能旧,直到用户点内置「刷新」或本 Action 声明了 `reloadTable`。 +- **仅数据管理交互路径生效**(toolbar / row / batch / skill-detail / `replaces`);**cron 与 agent 忽略**(与 `interaction` 相同)。 +- 需匠厂宿主 **≥ 2.8.5**;更旧宿主会忽略未知字段(若未做严格 Schema 校验)。 +- **新技能优先写 `steps`**;`hostEffects` 仍可作为「只刷 UI」的简写。两者可并存;同一 UI 效果若两边都声明,宿主可能各跑一次。 + +### `hostEffects`(UI 副作用简写) + +```json +{ + "hostEffects": { + "onSuccess": ["reloadTable"], + "onFailure": ["clearSelection"] + } +} +``` + +| 字段 | 触发时机 | +|------|----------| +| `onSuccess` | **sync** 退出码 0;或 **async** 任务已被任务中心**接收**(不是 Job 跑完) | +| `onFailure` | **sync** 失败;或 **async** **启动被拒** | + +闭集 effect id:`reloadTable`(重载当前表 + 常伴随 Data Policy 再拉)、`refreshCatalog`(刷新技能目录)、`clearSelection`(清空多选)。未知 id 丢弃。 + +**async 注意**:`sync-data` 一类长同步若写 `onSuccess: ["reloadTable"]`,会在**入队成功时**就刷表,可能仍是旧数据;更适合 sync 的新建/登记,或等 Job 真正完成后再刷(若宿主后续扩展完成回调)。 + +### `steps`(有序编排,推荐) + +```json +{ + "steps": [ + { "when": "before", "do": "deleteByFk", "table": "child_rows", "fk": "parent_id" }, + { "when": "afterSuccess", "do": "reloadTable" }, + { "when": "afterFailure", "do": "clearSelection" } + ] +} +``` + +| `when` | 含义 | +|--------|------| +| `before` | 调用 CLI **之前** | +| `afterSuccess` | 同 `hostEffects.onSuccess` | +| `afterFailure` | 同 `hostEffects.onFailure` | + +| `do` | 说明 | +|------|------| +| `reloadTable` / `refreshCatalog` / `clearSelection` | 同 hostEffects | +| `deleteByFk` | 按外键批量删子表;须 `table` + `fk`;行按钮会把当前行主键当父键 | +| `deleteRows` | 删指定表行;须 `table` | + +规则:单次最多 **8** 步;未知步骤丢弃;`deleteByFk` / `deleteRows` 仅走 Skill Action steps 路径时可删 `metadata_readonly` 投影表(人工 CRUD 仍守只读)。整库只读 / 视图仍禁止写。 + +典型:业务表「新建 / 登记 / 更新」成功后加 `{ "when": "afterSuccess", "do": "reloadTable" }`,避免用户傻等旧表格。 + +```json +{ + "id": "register-one", + "label": "登记本行", + "description": "更新当前行业务字段。", + "placements": ["row"], + "executionProfile": "sync", + "interaction": "form", + "bind": { + "tables": ["business_records"], + "inputMapping": { "recordId": "$row.$pk" } + }, + "steps": [{ "when": "afterSuccess", "do": "reloadTable" }], + "entrypoint": { + "type": "cli", + "command": "register", + "args": ["--id", "{{recordId}}"] + } +} +``` + 默认模板 `actions.json` 仅含 `health` / `version` / `config-path`,不放在 toolbar/row/batch,故无需 `bind` 示例污染默认 manifest。业务示例: ```json diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py index f479200..a7da68d 100644 --- a/tests/test_actions_manifest.py +++ b/tests/test_actions_manifest.py @@ -255,6 +255,30 @@ class TestActionsManifest(unittest.TestCase): {"create", "update", "delete"}, ) + 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 @@ -518,6 +542,11 @@ class TestActionsManifest(unittest.TestCase): "行右键", "六职责正交", "POLICY.md", + "hostEffects", + "steps", + "reloadTable", + "afterSuccess", + "afterFailure", ): self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") self.assertNotIn(