diff --git a/CHANGELOG.md b/CHANGELOG.md index bffcdf3..4e88afc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.64 + +- Skill Action 新增可选 `replaces: create|update|delete`:接管数据管理默认新增/编辑/删除,无配置时宿主仍直写 SQLite +- 更新 ACTIONS.md 与 skill-actions.schema.json;`replaces` 须配合 `bind.tables`,可与空 `placements` 并用 + ## 1.0.63 - 数据管理支持字段级 lookup:`options_json` 可声明同库关联表(`ref_table` / `value_column` / `label_column`),宿主新建/编辑时渲染下拉 diff --git a/SKILL.md b/SKILL.md index 8c5086e..5bbd331 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.63 +version: 1.0.64 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json index dd3b188..0016bd6 100644 --- a/assets/schemas/skill-actions.schema.json +++ b/assets/schemas/skill-actions.schema.json @@ -28,6 +28,11 @@ "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], "description": "稳定支持 toolbar/row/batch/cron/agent/skill-detail(对齐匠厂宿主)。placements 与 executionProfile 正交,不做条件限制。" }, + "mutationKind": { + "type": "string", + "enum": ["create", "update", "delete"], + "description": "可选。接管数据管理默认按钮:create=新增、update=编辑、delete=删除。须配合非空 bind.tables;无配置时宿主仍直写 SQLite。" + }, "scalarArg": { "type": ["string", "number", "boolean"] }, @@ -144,10 +149,11 @@ }, "placements": { "type": "array", - "minItems": 1, + "minItems": 0, "items": { "$ref": "#/$defs/placement" }, - "description": "决定 Action 出现在哪里;与 executionProfile 正交" + "description": "决定 Action 出现在哪里;与 executionProfile 正交。仅 replaces 接管默认 CRUD 时可为空数组。" }, + "replaces": { "$ref": "#/$defs/mutationKind" }, "entrypoint": { "$ref": "#/$defs/entrypoint" }, "inputSchema": { "$ref": "#/$defs/inputSchema" }, "confirmation": { "$ref": "#/$defs/confirmation" }, @@ -170,9 +176,12 @@ "batch": { "type": "string", "enum": ["direct", "confirm", "form"] }, "cron": { "type": "string", "enum": ["direct", "confirm", "form"] }, "agent": { "type": "string", "enum": ["direct", "confirm", "form"] }, - "skill-detail": { "type": "string", "enum": ["direct", "confirm", "form"] } + "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"] } }, - "description": "按 placement 覆盖 interaction(只能写 concrete 模式,不能写 auto)。常用于同一 Action 在 row 直通、toolbar 仍要表单。" + "description": "按 placement / replaces 覆盖 interaction(只能写 concrete 模式,不能写 auto)。" }, "bind": { "$ref": "#/$defs/bind" } }, @@ -187,7 +196,17 @@ "then": { "required": ["bind"] }, - "description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 时必须声明 bind.tables。" + "allOf": [ + { + "if": { + "required": ["replaces"] + }, + "then": { + "required": ["bind"] + } + } + ], + "description": "concurrency/locks 为未稳定预留字段,勿写入新技能 manifest。placements 含 toolbar 或声明 replaces 时必须声明 bind.tables。无 replaces 时 placements 建议非空(模板测试强制)。" } } } diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 57c03d6..73a0454 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -81,9 +81,10 @@ Action 是**可选能力**: | 字段 / 层 | 只决定什么 | 不决定什么 | |-----------|------------|------------| | **`placements`** | Action **出现在哪里**(toolbar / row / batch / cron / agent / skill-detail) | 不决定 sync/async,不决定点了是否弹窗 | +| **`replaces`** | **可选**:接管数据管理默认「新增 / 编辑 / 删除」 | 不强制;无配置时宿主仍直写 SQLite | | **`bind.tables`** | 数据管理里出现在**哪些业务表** | 不决定业务实现 | | **`executionProfile`** | 宿主是**同步等待**还是**异步 Job** | 不限制可展示入口,不决定是否弹窗 | -| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | +| **`interaction`** | 可点击入口(数据管理 toolbar/row/batch、默认 CRUD 接管、未来 skill-detail)**点了怎么交互** | 不决定 sync/async;**cron 触发与 agent 忽略** | | **`entrypoint`** | 实际执行哪个 CLI 命令与参数 | 不写业务逻辑 | | **domain service** | **真正完成什么业务** | 不感知 toolbar / cron / agent 来源 | @@ -94,9 +95,10 @@ Action 是**可选能力**: | 层级 | 稳定字段 / 能力 | |------|----------------| | 顶层 | `schemaVersion`、`skill`、`actions` | -| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`** | +| action | `id`、`label`、`description`、`placements`、`entrypoint`、**`executionProfile`(必填)**;可选 `inputSchema`、`confirmation`、**`interaction`**、**`interactionByPlacement`**、**`bind`**、**`replaces`** | | entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | -| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron` | +| placements | `toolbar`、`row`、`batch`、`skill-detail`、`agent`、`cron`;仅 `replaces` 时可为空数组 | +| **replaces** | 可选 `"create"` \| `"update"` \| `"delete"` — 接管默认新增/编辑/删除(须 `bind.tables`) | | **bind** | `{ "tables": ["business_table"] }`;可选 `inputMapping`(行内/批量预填,见下节) | | inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive`、`readOnly` | | confirmation | `{ "message": "..." }` — **风险文案**(confirm/form 弹窗黄条;agent 元数据);**不是**「要不要弹窗」的开关 | @@ -125,6 +127,47 @@ Action 是**可选能力**: - **`inputMapping`(可选)**:把行/勾选映射到 `inputSchema` 字段,例如 `"leadId": "$row.$pk"`、`"ids": "$selection.ids"`。适合 `row` / `batch`。宿主仍会提交这些字段;可用 `inputSchema.properties.*.readOnly: true` 在表单中只读展示。 - `concurrency` / `locks` 仍未稳定,**不要**写入新技能 manifest。 +## `replaces`(可选:接管默认新增 / 编辑 / 删除) + +有领域副作用的表(建目录、加密、级联清理)不要依赖宿主直写 SQLite。此时可为该表声明: + +```json +{ + "id": "account-add-web", + "label": "新增账号", + "description": "登记网页账号并初始化 profile 目录。", + "replaces": "create", + "placements": [], + "executionProfile": "sync", + "interaction": "form", + "bind": { "tables": ["accounts"] }, + "inputSchema": { + "type": "object", + "required": ["platform", "accountLabel"], + "properties": { + "platform": { "type": "string", "title": "平台" }, + "accountLabel": { "type": "string", "title": "账号名称" } + } + }, + "entrypoint": { + "type": "cli", + "command": "account", + "args": ["add-web", "--platform", "{{platform}}", "--label", "{{accountLabel}}"] + } +} +``` + +规则: + +- **全可选**:无 `replaces` 时,宿主默认按钮仍直写库,行为与以前一致。 +- 取值:`create`(新增)/ `update`(编辑)/ `delete`(删除)。 +- **必须**非空 `bind.tables`;同一技能同一表同一 `replaces` 至多一个 Action。 +- 与 `placements` 正交:可只接管默认按钮(`placements: []`),不必再挂 toolbar「第二个新增」。 +- 点默认按钮时走该 Action 的 `interaction`(create/update 常用 `form`;delete 常用 `confirm`)。 +- `update` / `delete` 可用 `inputMapping`(`$row.$pk`、`$selection.ids`)预填主键。 +- 宿主导入仍直写:有 `replaces:create` 的表慎用宿主导入,或另做 import Action。 +- 整表 `_jiangchang_tables.readonly=1` 时仍隐藏写按钮(优先于 `replaces`)。 + ## `interaction`(点击交互方式) 与 `executionProfile` **正交**:一个 Action 可以是 `async` + `direct`(一点就进任务中心),也可以是 `sync` + `form`(填参后当场返回)。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 91bc8f1..7054f39 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -78,6 +78,8 @@ metadata: 不要把「外部来源只读」错误理解成「技能本地数据库一定只读」。技能通过 `_jiangchang_tables.readonly` 与 `_jiangchang_columns.editable` 声明本地表的实际写权限。 +若创建/更新/删除带有领域副作用(自动建目录、加密、级联清理),不要依赖宿主默认直写:在 `assets/actions.json` 用可选字段 `replaces: create|update|delete` 接管默认按钮,详见 [`ACTIONS.md`](ACTIONS.md)。 + ## 标准数据同步语义(外源 → 本地库) 适用于「同步 / refresh / collect」类能力。使用通用名称表述,勿把具体平台表名写进模板实现。 diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py index 081f0cd..8f27770 100644 --- a/tests/test_actions_manifest.py +++ b/tests/test_actions_manifest.py @@ -190,7 +190,21 @@ class TestActionsManifest(unittest.TestCase): placements = action.get("placements") self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") - self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") + replaces = action.get("replaces") + if replaces is not None: + self.assertIn( + replaces, + {"create", "update", "delete"}, + msg=f"{action_id} invalid replaces {replaces!r}", + ) + bind = action.get("bind") or {} + tables = bind.get("tables") or [] + self.assertTrue( + isinstance(tables, list) and len(tables) > 0, + msg=f"{action_id} replaces requires non-empty bind.tables", + ) + else: + self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") for placement in placements: self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}") @@ -228,12 +242,18 @@ 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"): + for key in ("toolbar", "row", "batch", "cron", "agent", "skill-detail", "create", "update", "delete"): self.assertIn(key, by_placement.get("properties") or {}) self.assertEqual( set((by_placement["properties"][key].get("enum") or [])), {"direct", "confirm", "form"}, ) + self.assertIn("replaces", props) + self.assertEqual(props["replaces"].get("$ref"), "#/$defs/mutationKind") + self.assertEqual( + set(schema["$defs"]["mutationKind"].get("enum") or []), + {"create", "update", "delete"}, + ) def test_manifest_validates_against_json_schema(self) -> None: try: