Compare commits
79
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a6f90c651f | ||
|
|
42240f5e9e | ||
|
|
11cbc30a10 | ||
|
|
62d72ad84c | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
9b0441ca56 | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
b6eaf3c5af | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
08595594d7 | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
17741851f5 | ||
|
|
d10446bea7 | ||
|
|
3ec138ba99 | ||
|
|
0ed05a2c5d | ||
|
|
ae1edefee6 | ||
|
|
6dbc7ed82b | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba |
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -617,7 +617,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test shard with Race Detection
|
||||
if: ${{ steps.select.outputs.affected == 'true' }}
|
||||
@@ -757,7 +763,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Test release scripts
|
||||
shell: bash
|
||||
@@ -1135,7 +1147,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Build
|
||||
run: make build
|
||||
@@ -1186,7 +1204,13 @@ jobs:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Install archive tooling
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run policy and shortcut coverage
|
||||
run: |
|
||||
@@ -1267,7 +1291,13 @@ jobs:
|
||||
|
||||
- name: Install archive tooling
|
||||
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
run: sudo apt-get update && sudo apt-get install -y zip unzip
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Run baseline unit tests with coverage
|
||||
if: steps.baseline-cache.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -2698,9 +2698,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
@@ -6,6 +6,31 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
|
||||
## [1.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCliBeta < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.59-beta.3"
|
||||
version "1.0.59-beta.5"
|
||||
license "Apache-2.0"
|
||||
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
|
||||
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
|
||||
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
|
||||
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
|
||||
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
|
||||
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58"
|
||||
version "1.0.59"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
|
||||
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
|
||||
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
|
||||
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -68,6 +68,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
@@ -86,7 +97,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
|
||||
|
||||
@@ -99,9 +110,9 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
+3
-3
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -232,6 +232,67 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for the
|
||||
// reviewed Minutes/TODO/Wiki joint draft. Keeping candidate-only commands in a
|
||||
// separate map lets this test file land before the draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
@@ -531,6 +592,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -600,6 +675,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +808,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +817,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +831,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +843,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1091,7 +1209,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1304,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
+366
-3
@@ -7,6 +7,8 @@ import (
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
|
||||
return dsl, nil
|
||||
}
|
||||
|
||||
// executeAitableWorkflowPublish executes a publish exactly once, then requires
|
||||
// the reviewed valid/flowId envelope before rendering any success output.
|
||||
// create_workflow is non-idempotent, so transport uncertainty is never retried.
|
||||
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
|
||||
if deps.Caller.DryRun() {
|
||||
return callMCPToolOnServer("aitable", toolName, args)
|
||||
}
|
||||
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var envelope map[string]any
|
||||
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
|
||||
return fmt.Errorf("%s response is not a JSON object", toolName)
|
||||
}
|
||||
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s response validation failed: %w", toolName, err)
|
||||
}
|
||||
if !validFound {
|
||||
return fmt.Errorf("%s response is missing valid", toolName)
|
||||
}
|
||||
if !valid {
|
||||
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
|
||||
}
|
||||
if flowID == "" {
|
||||
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
|
||||
}
|
||||
return RenderLegacyMCPText(toolName, raw)
|
||||
}
|
||||
|
||||
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
|
||||
var visit func(map[string]any) error
|
||||
visit = func(object map[string]any) error {
|
||||
if raw, exists := object["valid"]; exists {
|
||||
value, ok := raw.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("valid must be boolean, got %T", raw)
|
||||
}
|
||||
if validFound && valid != value {
|
||||
return fmt.Errorf("conflicting valid values")
|
||||
}
|
||||
valid, validFound = value, true
|
||||
}
|
||||
for _, key := range []string{"flowId", "workflowId"} {
|
||||
raw, exists := object[key]
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
value, ok := raw.(string)
|
||||
value = strings.TrimSpace(value)
|
||||
if !ok || value == "" {
|
||||
return fmt.Errorf("%s must be a non-empty string", key)
|
||||
}
|
||||
if flowID != "" && flowID != value {
|
||||
return fmt.Errorf("conflicting workflow IDs")
|
||||
}
|
||||
flowID = value
|
||||
}
|
||||
for _, key := range []string{"data", "result", "response"} {
|
||||
if nested, ok := object[key].(map[string]any); ok {
|
||||
if err := visit(nested); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if envelope == nil {
|
||||
return false, false, "", fmt.Errorf("empty response")
|
||||
}
|
||||
if err := visit(envelope); err != nil {
|
||||
return false, false, "", err
|
||||
}
|
||||
return valid, validFound, flowID, nil
|
||||
}
|
||||
|
||||
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
|
||||
tableID, _ := cmd.Flags().GetString("table-id")
|
||||
tableID = strings.TrimSpace(tableID)
|
||||
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
|
||||
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
|
||||
}
|
||||
|
||||
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
|
||||
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
jsonStr, _ := cmd.Flags().GetString("json")
|
||||
if jsonStr == "" {
|
||||
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
|
||||
}
|
||||
var parsed any
|
||||
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
|
||||
return fmt.Errorf("--json 解析失败: %v", err)
|
||||
}
|
||||
cfgMap := map[string]any{"filter": parsed}
|
||||
if err := normalizeViewConfigBlock(cfgMap); err != nil {
|
||||
return err
|
||||
}
|
||||
filter, _ := cfgMap["filter"].([]any)
|
||||
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "viewId": viewID,
|
||||
"config": map[string]any{"filter": filter},
|
||||
}
|
||||
if deps.Caller.DryRun() {
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
|
||||
})
|
||||
}
|
||||
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
|
||||
return err
|
||||
}
|
||||
var actual any
|
||||
var readBackErr error
|
||||
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 8*time.Second {
|
||||
backoff = 8 * time.Second
|
||||
}
|
||||
aitableViewFilterReadbackSleep(backoff)
|
||||
}
|
||||
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
|
||||
if err != nil {
|
||||
readBackErr = err
|
||||
continue
|
||||
}
|
||||
actualViewID, _ := view["viewId"].(string)
|
||||
if actualViewID != viewID {
|
||||
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
|
||||
continue
|
||||
}
|
||||
actual = walkViewPath(view, "filter")
|
||||
if persistedViewFilterMatches(actual, filter) {
|
||||
readBackErr = nil
|
||||
break
|
||||
}
|
||||
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
|
||||
}
|
||||
if readBackErr != nil {
|
||||
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
|
||||
}
|
||||
return deps.Out.PrintJSON(map[string]any{
|
||||
"success": true,
|
||||
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
|
||||
})
|
||||
}
|
||||
|
||||
const aitableViewFilterReadbackAttempts = 6
|
||||
|
||||
var aitableViewFilterReadbackSleep = time.Sleep
|
||||
|
||||
func persistedViewFilterMatches(actual any, expected []any) bool {
|
||||
if reflect.DeepEqual(actual, expected) {
|
||||
return true
|
||||
}
|
||||
root, ok := actual.(map[string]any)
|
||||
if !ok || root["operator"] != "and" {
|
||||
return false
|
||||
}
|
||||
operands, ok := root["operands"].([]any)
|
||||
return ok && reflect.DeepEqual(operands, expected)
|
||||
}
|
||||
|
||||
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
|
||||
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
|
||||
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
|
||||
}
|
||||
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("get_fields response is missing the fields collection")
|
||||
}
|
||||
types := make(map[string]string, len(fields))
|
||||
for index, field := range fields {
|
||||
fieldID, _ := field["fieldId"].(string)
|
||||
if strings.TrimSpace(fieldID) == "" {
|
||||
fieldID, _ = field["id"].(string)
|
||||
}
|
||||
fieldType, _ := field["type"].(string)
|
||||
if strings.TrimSpace(fieldType) == "" {
|
||||
fieldType, _ = field["fieldType"].(string)
|
||||
}
|
||||
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
|
||||
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
|
||||
}
|
||||
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
|
||||
wanted := make([]string, 0, len(names))
|
||||
seen := make(map[string]bool, len(names))
|
||||
for _, name := range names {
|
||||
name = strings.ToLower(name)
|
||||
if !seen[name] {
|
||||
wanted = append(wanted, name)
|
||||
seen[name] = true
|
||||
}
|
||||
}
|
||||
var walk func(any) ([]map[string]any, bool)
|
||||
walk = func(current any) ([]map[string]any, bool) {
|
||||
switch typed := current.(type) {
|
||||
case map[string]any:
|
||||
keys := make([]string, 0, len(typed))
|
||||
for key := range typed {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, name := range wanted {
|
||||
for _, key := range keys {
|
||||
if !strings.EqualFold(key, name) {
|
||||
continue
|
||||
}
|
||||
items, ok := typed[key].([]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
object, ok := item.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out = append(out, object)
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
}
|
||||
for _, key := range keys {
|
||||
if found, ok := walk(typed[key]); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
if found, ok := walk(child); ok {
|
||||
return found, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
return walk(value)
|
||||
}
|
||||
|
||||
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
|
||||
for index, raw := range filter {
|
||||
condition, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("filter[%d] must be an object", index)
|
||||
}
|
||||
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
|
||||
return fmt.Errorf("filter[%d]: %w", index, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
|
||||
operator, _ := condition["operator"].(string)
|
||||
operator = strings.TrimSpace(operator)
|
||||
if operator == "" || !validFilterOperators[operator] {
|
||||
return fmt.Errorf("unsupported operator %q", operator)
|
||||
}
|
||||
operands, ok := condition["operands"].([]any)
|
||||
if !ok {
|
||||
return fmt.Errorf("operator %s requires an operands array", operator)
|
||||
}
|
||||
if operator == "and" || operator == "or" {
|
||||
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
|
||||
}
|
||||
wantOperands := 2
|
||||
if operator == "exist" || operator == "un_exist" {
|
||||
wantOperands = 1
|
||||
}
|
||||
if len(operands) != wantOperands {
|
||||
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
|
||||
}
|
||||
fieldID, ok := operands[0].(string)
|
||||
fieldID = strings.TrimSpace(fieldID)
|
||||
if !ok || fieldID == "" {
|
||||
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
|
||||
}
|
||||
fieldType, exists := fieldTypes[fieldID]
|
||||
if !exists {
|
||||
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
|
||||
}
|
||||
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
|
||||
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
|
||||
}
|
||||
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
|
||||
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
|
||||
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
|
||||
}
|
||||
if operator == "any_of" {
|
||||
if values, ok := operands[1].([]any); ok {
|
||||
if err := validateAitableMultiSelectOptionNames(values); err != nil {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectOptionNames(values []any) error {
|
||||
if len(values) == 0 {
|
||||
return fmt.Errorf("multipleSelect any_of array must not be empty")
|
||||
}
|
||||
for index, value := range values {
|
||||
text, ok := value.(string)
|
||||
text = strings.TrimSpace(text)
|
||||
if !ok || text == "" {
|
||||
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isAitableDateFieldType(fieldType string) bool {
|
||||
return strings.EqualFold(fieldType, "date") ||
|
||||
strings.EqualFold(fieldType, "createdTime") ||
|
||||
strings.EqualFold(fieldType, "lastModifiedTime")
|
||||
}
|
||||
|
||||
func isAitableDateFilterOperator(operator string) bool {
|
||||
switch operator {
|
||||
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func validateAitableMultiSelectFilterValue(value any) error {
|
||||
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
|
||||
}
|
||||
|
||||
func compactJSON(value any) string {
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return fmt.Sprintf("%#v", value)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func newAitableCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/aitable.json
|
||||
// products.aitable). Catalog assembly stamps provenance contract_final.
|
||||
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
|
||||
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
|
||||
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runAitableViewUpdateArray(cmd, "filter")
|
||||
return runAitableViewUpdateFilter(cmd)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
|
||||
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
}
|
||||
// create_workflow is non-idempotent. Bypass the retry wrapper to
|
||||
// prevent an uncertain first response from creating a duplicate.
|
||||
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("create_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
|
||||
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
|
||||
toolArgs["locale"] = locale
|
||||
}
|
||||
return callAitableTool("update_workflow", toolArgs)
|
||||
return executeAitableWorkflowPublish("update_workflow", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
|
||||
|
||||
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
|
||||
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
oldArgs := os.Args
|
||||
t.Cleanup(func() { os.Args = oldArgs })
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
|
||||
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
|
||||
|
||||
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
|
||||
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
config, _ := caller.calls[1].args["config"].(map[string]any)
|
||||
if _, ok := config["filter"].([]any); !ok {
|
||||
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logical groups fail closed before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unknown field is rejected before write", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
|
||||
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
|
||||
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
|
||||
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
|
||||
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select operator rejects text field", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
|
||||
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
|
||||
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select array fails closed before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
|
||||
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
|
||||
caller := &aitableTestCaller{responses: []string{fields}}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
|
||||
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
|
||||
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("date and system-time fields require date operators", func(t *testing.T) {
|
||||
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
|
||||
for fieldID := range fieldTypes {
|
||||
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
|
||||
t.Fatalf("date_eq for %s: %v", fieldID, err)
|
||||
}
|
||||
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
|
||||
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
|
||||
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
|
||||
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read-back mismatch is not success", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
|
||||
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
|
||||
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
|
||||
testseam.Protect(t, &os.Args)
|
||||
os.Args = []string{"dws", "aitable"}
|
||||
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
|
||||
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
|
||||
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
|
||||
|
||||
t.Run("update transport error", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
|
||||
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback transport errors exhaust", func(t *testing.T) {
|
||||
errs := []error{nil, nil}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
errs = append(errs, context.DeadlineExceeded)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
|
||||
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("readback wrong identity exhausts", func(t *testing.T) {
|
||||
responses := []string{fields, `{"success":true}`}
|
||||
for range aitableViewFilterReadbackAttempts {
|
||||
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
|
||||
}
|
||||
caller := &aitableTestCaller{responses: responses}
|
||||
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
|
||||
t.Fatalf("wrong readback identity = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
loadCases := []struct {
|
||||
name string
|
||||
response string
|
||||
callErr error
|
||||
wantError string
|
||||
}{
|
||||
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
|
||||
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
|
||||
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
|
||||
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
|
||||
}
|
||||
for _, tc := range loadCases {
|
||||
t.Run("load fields "+tc.name, func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
|
||||
installAitableDeps(t, caller)
|
||||
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
|
||||
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("load legacy field keys", func(t *testing.T) {
|
||||
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
|
||||
installAitableDeps(t, caller)
|
||||
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
|
||||
if err != nil || got["legacy"] != "text" {
|
||||
t.Fatalf("legacy field keys = %#v, %v", got, err)
|
||||
}
|
||||
})
|
||||
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
|
||||
t.Fatal("scalar fields collection must fail")
|
||||
}
|
||||
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
|
||||
t.Fatal("scalar field item must fail")
|
||||
}
|
||||
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
|
||||
t.Fatalf("recursive fields = %#v, %v", got, ok)
|
||||
}
|
||||
if got, ok := findAitableObjectList(map[string]any{
|
||||
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
|
||||
"fields": []any{map[string]any{"fieldId": "canonical"}},
|
||||
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
|
||||
t.Fatalf("declared collection priority = %#v, %v", got, ok)
|
||||
}
|
||||
|
||||
invalidFilters := []struct {
|
||||
filter []any
|
||||
want string
|
||||
}{
|
||||
{filter: []any{"bad"}, want: "must be an object"},
|
||||
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
|
||||
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
|
||||
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
|
||||
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
|
||||
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
|
||||
}
|
||||
for _, tc := range invalidFilters {
|
||||
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
|
||||
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
|
||||
}
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
|
||||
t.Fatal("empty any_of array must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
|
||||
t.Fatal("non-string any_of option must fail")
|
||||
}
|
||||
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
|
||||
t.Fatalf("valid any_of option names = %v", err)
|
||||
}
|
||||
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
|
||||
t.Fatalf("compactJSON fallback = %q", got)
|
||||
}
|
||||
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
|
||||
t.Fatal("invalid persisted wrapper must not match")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
|
||||
responses []string
|
||||
errors []error
|
||||
calls []aitableTestCall
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
|
||||
}
|
||||
return textToolResult(response), nil
|
||||
}
|
||||
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
return c.CallTool(ctx, server, tool, args)
|
||||
}
|
||||
func (*aitableTestCaller) Format() string { return "json" }
|
||||
func (*aitableTestCaller) DryRun() bool { return false }
|
||||
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableTestCaller) Fields() string { return "" }
|
||||
func (*aitableTestCaller) JQ() string { return "" }
|
||||
|
||||
|
||||
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
|
||||
}
|
||||
|
||||
type aitableWorkflowCaller struct {
|
||||
calls []aitableWorkflowCall
|
||||
calls []aitableWorkflowCall
|
||||
response string
|
||||
err error
|
||||
dryRun bool
|
||||
}
|
||||
|
||||
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
response := c.response
|
||||
if response == "" {
|
||||
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
|
||||
Text: response,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableWorkflowCaller) Format() string { return "json" }
|
||||
func (*aitableWorkflowCaller) DryRun() bool { return false }
|
||||
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
|
||||
func (*aitableWorkflowCaller) Fields() string { return "" }
|
||||
func (*aitableWorkflowCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableWorkflowCaller{}
|
||||
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
|
||||
}
|
||||
|
||||
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableWorkflowCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
|
||||
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
cmd := newAitableCommand()
|
||||
cmd.PersistentFlags().String("format", "json", "output format")
|
||||
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
|
||||
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs(append([]string{"workflow"}, args...))
|
||||
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
|
||||
stdin = strings.NewReader("")
|
||||
}
|
||||
cmd.SetIn(stdin)
|
||||
return caller, cmd.Execute()
|
||||
return cmd.Execute()
|
||||
}
|
||||
|
||||
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
wantDSL := map[string]any{
|
||||
"version": "workflow-dsl/v1",
|
||||
"name": "create test",
|
||||
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
response string
|
||||
want string
|
||||
}{
|
||||
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
|
||||
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
|
||||
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
|
||||
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
|
||||
{name: "malformed response", response: `{`, want: "not a JSON object"},
|
||||
{name: "null response", response: `null`, want: "not a JSON object"},
|
||||
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
|
||||
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
|
||||
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: tc.response}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("update uses the same strict contract", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
|
||||
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry-run does not call publish tool", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{dryRun: true}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 {
|
||||
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("transport error", func(t *testing.T) {
|
||||
caller := &aitableWorkflowCaller{err: context.Canceled}
|
||||
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
|
||||
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
|
||||
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
|
||||
t.Fatalf("nil workflow envelope = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
|
||||
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
|
||||
if err != nil {
|
||||
|
||||
@@ -10569,7 +10569,7 @@ pl_PL, sv_SE, fi_FI, cs_CZ, ar_SA, tl_PH, he_IL, nl_NL, lo_LA, it_IT`,
|
||||
chatCategoryCmd.AddCommand(chatCategoryCreateSmartCmd)
|
||||
chatMessageCmd.AddCommand(chatMessageListDirectCmd, chatMessageSearchCommonCmd, chatMessageCombineForwardCmd, chatMessageForwardTopicCmd, chatMessageSetPinCmd, chatMessageUnsetPinCmd, chatMessageListPinCmd, chatMessageAddFavoriteCmd, chatMessageRemoveFavoriteCmd, chatMessageListFavoritesCmd, chatMessageSetTopMsgCmd, chatMessageUnsetTopMsgCmd, chatMessageListEmotionRepliesCmd)
|
||||
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand())
|
||||
root.AddCommand(chatChmodCmd, chatDataAuthCmd, chatGroupCmd, chatSearchCmd, chatSearchCommonCmd, chatMessageCmd, chatFileCmd, newChatMediaGroup(), chatBotCmd, chatMessageListTopConversationsCmd, chatConversationInfoCmd, chatCategoryCmd, chatGroupRoleCmd, chatMuteCmd, chatSetTopCmd, chatGroupMuteCmd, chatGroupMuteMemberCmd, chatHideCmd, chatMuteAtAllCmd, chatMuteRedEnvelopeCmd, chatMarkUnreadCmd, chatClearRedPointCmd, chatClearAllRedPointCmd, chatListAllConversationsCmd, chatClearMessagesCmd, chatMarkReadCmd, chatTextCmd, newChatToolbarCommand(), newChatEmotionCommand())
|
||||
|
||||
// Keep the v1.0.56 command surface recognizable while directing callers to
|
||||
// the supported nested commands. The chat root's "im" alias makes these
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
|
||||
|
||||
func newChatEmotionCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "emotion",
|
||||
Short: "个人收藏表情",
|
||||
Long: "查询、发送和新增当前用户的个人收藏表情。",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newChatEmotionListCommand(),
|
||||
newChatEmotionSendCommand(),
|
||||
newChatEmotionFavoriteCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionListCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "列出个人收藏表情",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "list_personal_emotions",
|
||||
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
|
||||
},
|
||||
Description: "列出当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出当前用户的个人收藏表情",
|
||||
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
|
||||
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
|
||||
Examples: []string{"dws chat emotion list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionSendCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "send",
|
||||
Short: "发送个人收藏表情",
|
||||
Long: `发送当前用户的个人收藏表情。
|
||||
|
||||
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
|
||||
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
target, err := personalEmotionSendTarget(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
emotionID, _ := cmd.Flags().GetString("emotion-id")
|
||||
if strings.TrimSpace(emotionID) != "" {
|
||||
payload["emotionId"] = strings.TrimSpace(emotionID)
|
||||
}
|
||||
for key, value := range target {
|
||||
payload[key] = value
|
||||
}
|
||||
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
|
||||
payload["uuid"] = uuid
|
||||
}
|
||||
return callMCPToolOnServer("im", "send_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
|
||||
cmd.Flags().String("emotion-id", "", "表情 ID")
|
||||
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
|
||||
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
|
||||
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
|
||||
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
|
||||
cmd.Flags().String("uuid", "", "幂等键")
|
||||
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
|
||||
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
|
||||
})
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionSendContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newChatEmotionFavoriteCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "favorite",
|
||||
Short: "新增个人收藏表情",
|
||||
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
mediaID, _ := cmd.Flags().GetString("media-id")
|
||||
if strings.TrimSpace(mediaID) == "" {
|
||||
return fmt.Errorf("--media-id is required")
|
||||
}
|
||||
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
|
||||
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
|
||||
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
|
||||
return err
|
||||
}
|
||||
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
|
||||
name, _ := cmd.Flags().GetString("name")
|
||||
if strings.TrimSpace(name) != "" {
|
||||
payload["name"] = strings.TrimSpace(name)
|
||||
}
|
||||
if strings.TrimSpace(sourceConversationID) != "" {
|
||||
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
|
||||
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
|
||||
}
|
||||
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
|
||||
},
|
||||
}
|
||||
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
|
||||
cmd.Flags().String("name", "", "表情名称")
|
||||
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
|
||||
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
|
||||
DeclareLeafMetadata(cmd, LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
|
||||
},
|
||||
Contract: personalEmotionFavoriteContract(),
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
|
||||
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
|
||||
userID, _ := cmd.Flags().GetString("user")
|
||||
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
|
||||
userID = strings.TrimSpace(userID)
|
||||
openDingTalkID = strings.TrimSpace(openDingTalkID)
|
||||
specified := 0
|
||||
for _, value := range []string{groupID, userID, openDingTalkID} {
|
||||
if value != "" {
|
||||
specified++
|
||||
}
|
||||
}
|
||||
if specified != 1 {
|
||||
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
|
||||
}
|
||||
if groupID != "" {
|
||||
return map[string]any{"openConversationId": groupID}, nil
|
||||
}
|
||||
if openDingTalkID != "" {
|
||||
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
|
||||
}
|
||||
if isOpenDingTalkID(userID) {
|
||||
return map[string]any{"receiverOpenDingTalkId": userID}, nil
|
||||
}
|
||||
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
|
||||
}
|
||||
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
|
||||
}
|
||||
|
||||
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
|
||||
hasConversation := strings.TrimSpace(sourceConversationID) != ""
|
||||
hasMessage := strings.TrimSpace(sourceMessageID) != ""
|
||||
if hasConversation != hasMessage {
|
||||
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalEmotionSendContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "send_personal_emotion",
|
||||
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
|
||||
},
|
||||
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "以当前用户身份发送个人收藏表情",
|
||||
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
|
||||
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
|
||||
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
|
||||
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
|
||||
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
|
||||
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
|
||||
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func personalEmotionFavoriteContract() LeafContract {
|
||||
return LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "chat", Name: "favorite_personal_emotion",
|
||||
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
|
||||
},
|
||||
Description: "将 mediaId 新增到当前用户的个人收藏表情",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "新增当前用户的个人收藏表情",
|
||||
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
|
||||
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
|
||||
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
|
||||
{Name: "name", Property: "name", Required: boolPtr(false)},
|
||||
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
|
||||
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type personalEmotionCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type personalEmotionCaller struct {
|
||||
calls []personalEmotionCall
|
||||
}
|
||||
|
||||
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copied := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copied[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
|
||||
if server == "contact" && tool == "get_user_info_by_user_ids" {
|
||||
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
|
||||
}
|
||||
return textToolResult(`{"ok":true}`), nil
|
||||
}
|
||||
|
||||
func (*personalEmotionCaller) Format() string { return "json" }
|
||||
func (*personalEmotionCaller) DryRun() bool { return false }
|
||||
func (*personalEmotionCaller) Fields() string { return "" }
|
||||
func (*personalEmotionCaller) JQ() string { return "" }
|
||||
|
||||
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
|
||||
t.Helper()
|
||||
installHelpersCoreDeps(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
root := newChatCommand()
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
return root.ExecuteContext(context.Background())
|
||||
}
|
||||
|
||||
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
|
||||
t.Helper()
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "im" || call.tool != tool {
|
||||
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args, want) {
|
||||
t.Fatalf("args = %#v, want %#v", call.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
|
||||
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
|
||||
caller := &personalEmotionCaller{}
|
||||
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
|
||||
t.Fatalf("chat emotion list returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
|
||||
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--emotion-id", "emotion123",
|
||||
"--group", "cid123",
|
||||
"--idempotency-key", "idem-001",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"emotionId": "emotion123",
|
||||
"openConversationId": "cid123",
|
||||
"uuid": "idem-001",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
|
||||
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--open-dingtalk-id", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
|
||||
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", helperCurrentDOpenID,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID,
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
|
||||
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "send",
|
||||
"--media-id", "@media",
|
||||
"--user", "u1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion send returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 2 {
|
||||
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
|
||||
}
|
||||
resolveCall := caller.calls[0]
|
||||
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
|
||||
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
|
||||
}
|
||||
sendCall := caller.calls[1]
|
||||
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
|
||||
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
|
||||
}
|
||||
want := map[string]any{
|
||||
"mediaId": "@media",
|
||||
"receiverOpenDingTalkId": helperCurrentDOpenID2,
|
||||
}
|
||||
if !reflect.DeepEqual(sendCall.args, want) {
|
||||
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"emotion", "send", "--media-id", "@media"},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "multiple targets",
|
||||
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
|
||||
wantErr: "specify exactly one",
|
||||
},
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "send", "--group", "cid"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
|
||||
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller,
|
||||
"emotion", "favorite",
|
||||
"--media-id", "@media",
|
||||
"--name", "赞",
|
||||
"--source-conversation-id", "cid123",
|
||||
"--source-message-id", "msg123",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat emotion favorite returned error: %v", err)
|
||||
}
|
||||
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
|
||||
"mediaId": "@media",
|
||||
"name": "赞",
|
||||
"sourceConversationId": "cid123",
|
||||
"sourceMessageId": "msg123",
|
||||
})
|
||||
}
|
||||
|
||||
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing media",
|
||||
args: []string{"emotion", "favorite", "--name", "赞"},
|
||||
wantErr: "--media-id is required",
|
||||
},
|
||||
{
|
||||
name: "source conversation only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
{
|
||||
name: "source message only",
|
||||
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
|
||||
wantErr: "must be specified together",
|
||||
},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &personalEmotionCaller{}
|
||||
err := executePersonalEmotionCommand(t, caller, tc.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令"},
|
||||
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"additionalProperties":false
|
||||
}`),
|
||||
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
|
||||
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
|
||||
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
|
||||
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
|
||||
},
|
||||
"oneOf":[
|
||||
{"required":["detection","diff","summary","items"]},
|
||||
|
||||
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
|
||||
if name == "sync" {
|
||||
assertDriveSyncResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
if name == "push" {
|
||||
assertDrivePushResultSchema(t, final.Result.DataSchema)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
t.Helper()
|
||||
properties := resultSchemaProperties(t, raw)
|
||||
plan := schemaObjectProperty(t, properties, "plan")
|
||||
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
|
||||
summaryProperties := schemaProperties(t, summary)
|
||||
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
|
||||
if _, ok := summaryProperties[name]; !ok {
|
||||
t.Fatalf("push plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
|
||||
t.Helper()
|
||||
var schema map[string]any
|
||||
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
|
||||
planProperties := schemaProperties(t, plan)
|
||||
planDiff := schemaObjectProperty(t, planProperties, "diff")
|
||||
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
|
||||
planSummary := schemaObjectProperty(t, planProperties, "summary")
|
||||
planSummaryProperties := schemaProperties(t, planSummary)
|
||||
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
|
||||
if _, ok := planSummaryProperties[name]; !ok {
|
||||
t.Fatalf("sync plan summary schema is missing %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
var schema map[string]any
|
||||
if err := json.Unmarshal(raw, &schema); err != nil {
|
||||
|
||||
@@ -39,6 +39,11 @@ const (
|
||||
pushActionSkipped = "skipped" // 按 --if-exists 跳过
|
||||
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
|
||||
pushActionFailed = "failed"
|
||||
|
||||
pushActionPlannedUpload = "planned_upload"
|
||||
pushActionPlannedOverwrite = "planned_overwrite"
|
||||
pushActionPlannedSkip = "planned_skip"
|
||||
pushActionPlannedFolderCreate = "planned_folder_create"
|
||||
)
|
||||
|
||||
// localPushFile 描述一个待推送的本地常规文件。
|
||||
@@ -79,10 +84,13 @@ type drivePushItem struct {
|
||||
|
||||
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
|
||||
type drivePushSummary struct {
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
Uploaded int `json:"uploaded"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Aborted bool `json:"aborted"`
|
||||
PlannedUploads int `json:"planned_uploads,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
type drivePushResult struct {
|
||||
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
|
||||
plan.Summary.PlannedFolders++
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
|
||||
}
|
||||
for _, lf := range localFiles {
|
||||
size := lf.Size
|
||||
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
action := pushActionUploaded
|
||||
action := pushActionPlannedUpload
|
||||
if rf, exists := remoteFiles[lf.RelPath]; exists {
|
||||
switch ifExists {
|
||||
case ifExistsSkip:
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
case ifExistsSmart:
|
||||
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
|
||||
action = pushActionSkipped
|
||||
action = pushActionPlannedSkip
|
||||
} else {
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
case ifExistsOverwrite:
|
||||
action = pushActionOverwritten
|
||||
action = pushActionPlannedOverwrite
|
||||
}
|
||||
}
|
||||
if action == pushActionSkipped {
|
||||
plan.Summary.Skipped++
|
||||
if action == pushActionPlannedSkip {
|
||||
plan.Summary.PlannedSkips++
|
||||
} else {
|
||||
plan.Summary.Uploaded++
|
||||
plan.Summary.PlannedUploads++
|
||||
}
|
||||
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
|
||||
}
|
||||
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
return fmt.Errorf("failed to seek upload file: %w", err)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
|
||||
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
|
||||
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
|
||||
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create upload request: %w", err)
|
||||
}
|
||||
|
||||
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
|
||||
_, _ = io.Copy(io.Discard, request.Body)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
path := filepath.Join(t.TempDir(), "payload")
|
||||
mustWrite(t, path, "payload")
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
|
||||
t.Fatalf("PUT failed: %v", err)
|
||||
}
|
||||
if _, err := file.Stat(); err != nil {
|
||||
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
|
||||
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
|
||||
_, root, _ := openDriveFinalCoverageRoot(t)
|
||||
|
||||
@@ -65,6 +65,14 @@ const (
|
||||
syncActionRenamedLocal = "renamed_local"
|
||||
syncActionSkipped = "skipped"
|
||||
syncActionFailed = "failed"
|
||||
|
||||
syncActionPlannedDownload = "planned_download"
|
||||
syncActionPlannedUpload = "planned_upload"
|
||||
syncActionPlannedOverwrite = "planned_overwrite"
|
||||
syncActionPlannedFolderCreate = "planned_folder_create"
|
||||
syncActionPlannedRenameLocal = "planned_rename_local"
|
||||
syncActionPlannedSkip = "planned_skip"
|
||||
syncActionDecisionRequired = "decision_required"
|
||||
)
|
||||
|
||||
// driveSyncItem 是输出 items[] 中每条操作的明细。
|
||||
@@ -77,10 +85,14 @@ type driveSyncItem struct {
|
||||
|
||||
// driveSyncSummary 是各方向的计数汇总。
|
||||
type driveSyncSummary struct {
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
Pulled int `json:"pulled"`
|
||||
Pushed int `json:"pushed"`
|
||||
Skipped int `json:"skipped"`
|
||||
Failed int `json:"failed"`
|
||||
PlannedPulls int `json:"planned_pulls,omitempty"`
|
||||
PlannedPushes int `json:"planned_pushes,omitempty"`
|
||||
PlannedSkips int `json:"planned_skips,omitempty"`
|
||||
PlannedFolders int `json:"planned_folders,omitempty"`
|
||||
}
|
||||
|
||||
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
|
||||
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
|
||||
// --dry-run:只算差异、不执行任何同步动作。
|
||||
if deps.Caller.DryRun() {
|
||||
if len(preflight) == 0 && res.Summary.Failed == 0 {
|
||||
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
|
||||
})
|
||||
}
|
||||
return deps.Out.PrintJSON(driveSyncDryRunResult{
|
||||
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
|
||||
})
|
||||
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type driveSyncDryRunPlanInput struct {
|
||||
LocalDirs []string
|
||||
LocalByRel map[string]localPushFile
|
||||
RemoteFiles map[string]*remoteFile
|
||||
RemoteFolders map[string]string
|
||||
NewLocal []string
|
||||
NewRemote []string
|
||||
Modified []string
|
||||
Unknown []string
|
||||
OnConflict string
|
||||
}
|
||||
|
||||
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
|
||||
for _, rel := range input.Unknown {
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
|
||||
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
|
||||
})
|
||||
}
|
||||
|
||||
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
|
||||
for rel, fileID := range input.RemoteFolders {
|
||||
plannedFolders[rel] = fileID
|
||||
}
|
||||
for _, dir := range input.LocalDirs {
|
||||
if _, ok := plannedFolders[dir]; ok {
|
||||
continue
|
||||
}
|
||||
parentRel, _ := splitRel(dir)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
plannedFolders[dir] = "dry-run-planned-folder"
|
||||
res.Summary.PlannedFolders++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
for _, rel := range input.NewRemote {
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
}
|
||||
for _, rel := range input.NewLocal {
|
||||
parentRel, _ := splitRel(rel)
|
||||
if plannedFolders[parentRel] == "" {
|
||||
res.Summary.Failed++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
|
||||
continue
|
||||
}
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
|
||||
}
|
||||
|
||||
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
|
||||
for rel := range input.LocalByRel {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for _, rel := range input.LocalDirs {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFiles {
|
||||
occupied[rel] = true
|
||||
}
|
||||
for rel := range input.RemoteFolders {
|
||||
if rel != "" {
|
||||
occupied[rel] = true
|
||||
}
|
||||
}
|
||||
for _, rel := range input.Modified {
|
||||
switch input.OnConflict {
|
||||
case syncConflictRemoteWins:
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
|
||||
case syncConflictLocalWins:
|
||||
res.Summary.PlannedPushes++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
|
||||
case syncConflictKeepBoth:
|
||||
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
|
||||
occupied[candidate] = true
|
||||
res.Summary.PlannedPulls++
|
||||
res.Items = append(res.Items,
|
||||
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
|
||||
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
|
||||
)
|
||||
case syncConflictAsk:
|
||||
res.Items = append(res.Items, driveSyncItem{
|
||||
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
|
||||
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
|
||||
})
|
||||
default:
|
||||
res.Summary.PlannedSkips++
|
||||
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
|
||||
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
|
||||
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
|
||||
|
||||
@@ -3,6 +3,7 @@ package helpers
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
caller := syncCaller(nil)
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
|
||||
cmd := findDriveSubcommand(t, "push")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run push: %v", err)
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
|
||||
}
|
||||
plan := payload["plan"].(map[string]any)
|
||||
summary := plan["summary"].(map[string]any)
|
||||
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
|
||||
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
|
||||
}
|
||||
items := plan["items"].([]any)
|
||||
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
|
||||
t.Fatalf("dry-run action = %v, want planned_upload", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
|
||||
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
|
||||
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
caller := syncCaller(map[string]string{
|
||||
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
|
||||
})
|
||||
caller.dryRun = true
|
||||
|
||||
var out bytes.Buffer
|
||||
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
|
||||
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
|
||||
cmd := findDriveSubcommand(t, "sync")
|
||||
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("dry-run sync: %v", err)
|
||||
}
|
||||
|
||||
var payload driveSyncDryRunResult
|
||||
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
|
||||
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
|
||||
}
|
||||
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
|
||||
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
|
||||
}
|
||||
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
|
||||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
|
||||
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
|
||||
}
|
||||
actions := map[string]int{}
|
||||
for _, item := range payload.Plan.Items {
|
||||
actions[item.Action]++
|
||||
}
|
||||
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
|
||||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
|
||||
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
|
||||
}
|
||||
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
|
||||
if calls := caller.callsFor(tool); len(calls) != 0 {
|
||||
t.Fatalf("dry-run called %s: %#v", tool, calls)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
|
||||
remoteFiles := map[string]*remoteFile{
|
||||
"conflict.txt": {FileID: "remote-file"},
|
||||
}
|
||||
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
|
||||
localDirs := []string{"existing", "new", "missing/child"}
|
||||
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
|
||||
|
||||
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
|
||||
t.Run(policy, func(t *testing.T) {
|
||||
res := &driveSyncResult{}
|
||||
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
|
||||
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
|
||||
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
|
||||
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
|
||||
})
|
||||
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
|
||||
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
|
||||
}
|
||||
switch policy {
|
||||
case syncConflictRemoteWins, syncConflictKeepBoth:
|
||||
if res.Summary.PlannedPulls < 2 {
|
||||
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
|
||||
}
|
||||
case syncConflictLocalWins:
|
||||
if res.Summary.PlannedPushes < 2 {
|
||||
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
|
||||
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
|
||||
t.Fatalf("skip plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
|
||||
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
|
||||
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
|
||||
t.Fatalf("smart plan: %v", err)
|
||||
}
|
||||
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
|
||||
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
|
||||
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
|
||||
}
|
||||
out.Reset()
|
||||
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
|
||||
t.Fatalf("overwrite plan: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), pushActionOverwritten) {
|
||||
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
|
||||
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -557,13 +558,17 @@ func newWikiCommand() *cobra.Command {
|
||||
if err := validateRequiredFlags(cmd, "role"); err != nil {
|
||||
return err
|
||||
}
|
||||
role := normalizePermissionRole(mustGetFlag(cmd, "role"))
|
||||
if role == "OWNER" {
|
||||
return apperrors.NewValidation("OWNER 角色不可通过 wiki member add 添加")
|
||||
}
|
||||
userIds, err := collectUserIDs(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callMCPTool("add_member", map[string]any{
|
||||
"workspaceId": workspaceID,
|
||||
"roleId": normalizePermissionRole(mustGetFlag(cmd, "role")),
|
||||
"roleId": role,
|
||||
"userIds": userIds,
|
||||
})
|
||||
},
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -106,10 +108,17 @@ func TestCrossPlatformCoverageProxySubCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func executeWikiEdge(t *testing.T, args ...string) error {
|
||||
t.Helper()
|
||||
_, err := executeWikiEdgeWithCaller(t, args...)
|
||||
return err
|
||||
}
|
||||
|
||||
func executeWikiEdgeWithCaller(t *testing.T, args ...string) (*scriptedToolCaller, error) {
|
||||
t.Helper()
|
||||
oldDeps := deps
|
||||
oldArgs := os.Args
|
||||
InitDeps(&scriptedToolCaller{})
|
||||
caller := &scriptedToolCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
t.Cleanup(func() {
|
||||
@@ -125,7 +134,7 @@ func executeWikiEdge(t *testing.T, args ...string) error {
|
||||
root.SetIn(os.Stdin)
|
||||
root.SetArgs(args)
|
||||
os.Args = append([]string{"dws", "wiki"}, args...)
|
||||
return root.Execute()
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
|
||||
@@ -154,6 +163,20 @@ func TestCrossPlatformCoverageWikiRoutingAndValidationEdges(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiMemberAddRejectsOwner(t *testing.T) {
|
||||
caller, err := executeWikiEdgeWithCaller(t, "member", "add", "--workspace", "space", "--users", "u1", "--role", "OWNER")
|
||||
if err == nil || !strings.Contains(strings.ToUpper(err.Error()), "OWNER") {
|
||||
t.Fatalf("member add OWNER error = %v, want local OWNER rejection", err)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(err, &appErr) || appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("member add OWNER error = %#v, want validation category", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("member add OWNER made %d remote calls, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiDeleteCancellationEdges(t *testing.T) {
|
||||
oldStdin := os.Stdin
|
||||
t.Cleanup(func() { os.Stdin = oldStdin })
|
||||
|
||||
@@ -479,8 +479,8 @@ func evaluateCommandMigrationLifecycle(
|
||||
}
|
||||
}
|
||||
if allAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed command migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
if exists && proposed.State != CommandMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -383,9 +383,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
|
||||
{"after pending receipt", after, map[string]Snapshot{"main": before, "stable": before}, pending, pending, "without marking it consumed"},
|
||||
{"partial", partial, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "partially applied"},
|
||||
{"consumed drift", before, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed, "drifted from consumed"},
|
||||
{"stale receipt", after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed, "stale after all references"},
|
||||
{"early cleanup", after, map[string]Snapshot{"main": after, "stable": before}, consumed, empty, "must retain consumed"},
|
||||
{"consumed back to pending", after, map[string]Snapshot{"main": after, "stable": before}, consumed, pending, "must retain consumed"},
|
||||
{"inert consumed back to pending", after, map[string]Snapshot{"main": after, "stable": after}, consumed, pending, "back to pending"},
|
||||
{"candidate added consumed", after, map[string]Snapshot{"main": before, "stable": before}, empty, consumed, "must start pending"},
|
||||
{"candidate base mismatch", before, map[string]Snapshot{"main": after, "stable": before}, empty, pending, "does not match"},
|
||||
} {
|
||||
@@ -402,6 +402,9 @@ func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) {
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, empty); err != nil || len(got) != 0 {
|
||||
t.Fatalf("cleaned stale receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": after}, consumed, consumed); err != nil || len(got) != 0 {
|
||||
t.Fatalf("retained inert receipt=%#v, %v", got, err)
|
||||
}
|
||||
if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{"main": before, "stable": before}, empty, pending); err != nil || len(got) != 0 {
|
||||
t.Fatalf("candidate pending plan=%#v, %v", got, err)
|
||||
}
|
||||
|
||||
@@ -889,7 +889,7 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRetainsConsumedReceipt
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
func TestCrossPlatformCoverageCompareAllWithFlagMigrationsAllowsRetentionOrCleanupAfterAllReferencesCatchUp(t *testing.T) {
|
||||
after := testFlagMigrationSnapshot(true, true)
|
||||
consumed := testFlagMigrationManifest(FlagMigrationConsumed)
|
||||
references := map[string]Snapshot{
|
||||
@@ -897,11 +897,15 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsRequiresCleanupAfterAl
|
||||
"stable": after,
|
||||
}
|
||||
|
||||
if report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed); err == nil {
|
||||
t.Fatalf("stale consumed receipt was accepted after every reference caught up: %#v", report)
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, consumed)
|
||||
if err != nil {
|
||||
t.Fatalf("inert consumed receipt was rejected after every reference caught up: %v", err)
|
||||
}
|
||||
if !report.Compatible {
|
||||
t.Fatalf("inert consumed receipt changed compatibility: %#v", report.Comparisons)
|
||||
}
|
||||
|
||||
report, err := CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
report, err = CompareAllWithFlagMigrations(after, references, consumed, testEmptyFlagMigrationManifest())
|
||||
if err != nil {
|
||||
t.Fatalf("cleanup of stale consumed receipt was rejected: %v", err)
|
||||
}
|
||||
|
||||
@@ -579,8 +579,8 @@ func evaluateFlagMigrationLifecycle(
|
||||
}
|
||||
}
|
||||
if allReferencesAfter {
|
||||
if exists {
|
||||
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
|
||||
if exists && proposed.State != FlagMigrationConsumed {
|
||||
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "consumed receipt becomes stale",
|
||||
name: "inert consumed receipt cannot revert to pending",
|
||||
current: after,
|
||||
references: map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
authority: consumed,
|
||||
candidate: consumed,
|
||||
wantErr: "is stale after all references reached the after state",
|
||||
candidate: pending,
|
||||
wantErr: "back to pending",
|
||||
},
|
||||
{
|
||||
name: "candidate-added receipt starts pending",
|
||||
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
|
||||
_, err := CompareAllWithFlagMigrations(
|
||||
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
|
||||
report, err := CompareAllWithFlagMigrations(
|
||||
after,
|
||||
map[string]Snapshot{"merge-base": after, "stable": after},
|
||||
consumed,
|
||||
consumed,
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||
t.Fatalf("stale optional migration error = %v", err)
|
||||
if err != nil || !report.Compatible {
|
||||
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("copy_base", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"targetFolderId": rt.Str("target-folder-id"),
|
||||
"onlyCopyMeta": rt.Bool("only-struct"),
|
||||
})
|
||||
return executeBaseCopy(rt)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
|
||||
Command: "+record-query",
|
||||
Product: serverMain,
|
||||
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
|
||||
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
|
||||
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
|
||||
Risk: shortcut.RiskRead,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
|
||||
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
|
||||
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
|
||||
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
|
||||
Examples: []string{
|
||||
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
|
||||
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
|
||||
},
|
||||
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
|
||||
Execute: func(rt *shortcut.RuntimeContext) error {
|
||||
return rt.CallMCP("get_primary_doc", map[string]any{
|
||||
"baseId": rt.Str("base-id"),
|
||||
"tableId": rt.Str("table-id"),
|
||||
"recordId": rt.Str("record-id"),
|
||||
})
|
||||
return executeRecordPrimaryDocGet(rt)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
|
||||
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
|
||||
}
|
||||
row := map[string]any{}
|
||||
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
|
||||
if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
|
||||
row["workflowId"] = v
|
||||
}
|
||||
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
|
||||
|
||||
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
|
||||
t.Run("verified copy", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
|
||||
{text: `{"data":{"newBaseId":"new-base"}}`},
|
||||
{text: `{"data":{"baseId":"new-base","tables":[]}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
|
||||
t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{text: `{"success":true}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
|
||||
t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{err: errors.New("Invalid target folder ID")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects a non-folder target before write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
|
||||
t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects target metadata without a supported type", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects URL target before transport", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 0 {
|
||||
t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
|
||||
t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
|
||||
t.Run("invalid source", func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("invalid source = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("dry run", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{dryRun: true}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
|
||||
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
|
||||
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("target lookup error", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("generic copy error is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{err: errors.New("transport failed")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 2 {
|
||||
t.Fatalf("copy error = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
readBackCases := []struct {
|
||||
name string
|
||||
step upsertByKeyStep
|
||||
}{
|
||||
{name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
|
||||
{name: "missing id", step: upsertByKeyStep{text: `{}`}},
|
||||
{name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
|
||||
}
|
||||
for _, tc := range readBackCases {
|
||||
t.Run("readback "+tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
|
||||
{text: `{"newBaseId":"new-base"}`},
|
||||
tc.step,
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []string{"", "white space", "control\x00"} {
|
||||
if validCompositeOpaqueID(value) {
|
||||
t.Errorf("validCompositeOpaqueID(%q) = true", value)
|
||||
}
|
||||
}
|
||||
if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
|
||||
t.Fatal("pureNumericID edge mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func bootstrapFields(count int) []any {
|
||||
fields := make([]any, 0, count)
|
||||
for index := 0; index < count; index++ {
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func executeBaseCopy(rt *shortcut.RuntimeContext) error {
|
||||
baseID := strings.TrimSpace(rt.Str("base-id"))
|
||||
targetFolderID := strings.TrimSpace(rt.Str("target-folder-id"))
|
||||
if !validCompositeOpaqueID(baseID) {
|
||||
return apperrors.NewValidation("--base-id 不是合法的 Base ID")
|
||||
}
|
||||
if !validCompositeOpaqueID(targetFolderID) || pureNumericID(targetFolderID) {
|
||||
return apperrors.NewValidation("--target-folder-id 必须是文件夹 dentryUuid,不能是 URL、路径或空值")
|
||||
}
|
||||
params := map[string]any{
|
||||
"baseId": baseID,
|
||||
"targetFolderId": targetFolderID,
|
||||
"onlyCopyMeta": rt.Bool("only-struct"),
|
||||
}
|
||||
result := newCompositeResult("base_copy")
|
||||
result.Resolved = map[string]any{"sourceBaseId": baseID, "targetFolderId": targetFolderID}
|
||||
result.Plan = []compositeStep{
|
||||
{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "planned", Arguments: map[string]any{"fileId": targetFolderID}},
|
||||
{Index: 2, Name: "copy base", Tool: "copy_base", Status: "planned", Arguments: params},
|
||||
}
|
||||
if rt.DryRun() {
|
||||
result.Status = "planned"
|
||||
result.Executed = false
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
targetData, err := rt.CallMCPData("drive", "get_file_info", map[string]any{"fileId": targetFolderID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
actualTargetID := findStringByKeys(targetData, "fileId", "dentryUuid")
|
||||
if actualTargetID == "" || actualTargetID != targetFolderID {
|
||||
return apperrors.NewAPI("drive/get_file_info did not prove the exact target dentryUuid",
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
targetType := findStringByKeys(targetData, "type", "dentryType", "fileType")
|
||||
if targetType == "" {
|
||||
return apperrors.NewAPI("drive/get_file_info response is missing the target node type",
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_not_supported"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
if !strings.EqualFold(targetType, "folder") {
|
||||
return apperrors.NewAPI(fmt.Sprintf("target %s is %s, not a folder", targetFolderID, targetType),
|
||||
apperrors.WithOperation("drive/get_file_info"), apperrors.WithReason("target_wrong_type"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false), apperrors.WithRetryable(false))
|
||||
}
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 1, Name: "verify target folder", Tool: "drive/get_file_info", Status: "completed", Result: map[string]any{"fileId": actualTargetID, "type": targetType}})
|
||||
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "copy_base", params)
|
||||
newBaseID := findStringByKeys(writeData, "newBaseId")
|
||||
if newBaseID == "" && copyBaseRejectedTarget(writeErr) {
|
||||
return apperrors.NewAPI("copy_base rejected a target that Drive proved is the exact folder dentryUuid",
|
||||
apperrors.WithOperation("aitable/copy_base"), apperrors.WithOrigin("mcp"),
|
||||
apperrors.WithReason("target_not_supported"), apperrors.WithFailureStage("copy_base"),
|
||||
apperrors.WithExecutionStarted(true), apperrors.WithRetryable(false), apperrors.WithCause(writeErr),
|
||||
apperrors.WithHint("the current copy_base service does not accept this Drive folder contract; retrying or substituting spaceId/nodeId/dentryId is unsafe"))
|
||||
}
|
||||
if newBaseID == "" || !validCompositeOpaqueID(newBaseID) {
|
||||
cause := fmt.Errorf("copy_base response is missing a valid newBaseId")
|
||||
if writeErr != nil {
|
||||
cause = fmt.Errorf("copy_base response error: %w; newBaseId is unavailable", writeErr)
|
||||
}
|
||||
result.Status = "unknown"
|
||||
return compositeError(result, cause, false)
|
||||
}
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "copy_base", "newBaseId": newBaseID})
|
||||
|
||||
readBack, verifyErr := rt.CallMCPData(serverMain, "get_base", map[string]any{"baseId": newBaseID})
|
||||
if verifyErr == nil {
|
||||
actualID := findStringByKeys(readBack, "baseId")
|
||||
if actualID == "" {
|
||||
verifyErr = fmt.Errorf("get_base read-back is missing baseId")
|
||||
} else if actualID != newBaseID {
|
||||
verifyErr = fmt.Errorf("get_base read-back identity mismatch: got %q, want %q", actualID, newBaseID)
|
||||
}
|
||||
}
|
||||
if verifyErr != nil {
|
||||
result.Status = "partial_success"
|
||||
result.Verification = map[string]any{"status": "failed", "newBaseId": newBaseID, "error": verifyErr.Error()}
|
||||
return compositeError(result, verifyErr, false)
|
||||
}
|
||||
|
||||
result.CompletedCount = 2
|
||||
result.CompletedSteps = append(result.CompletedSteps, compositeStep{Index: 2, Name: "copy base", Tool: "copy_base", Status: "completed", Result: map[string]any{"newBaseId": newBaseID}})
|
||||
result.Verification = map[string]any{"status": "verified", "newBaseId": newBaseID}
|
||||
result.Result = map[string]any{"newBaseId": newBaseID, "base": readBack}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func copyBaseRejectedTarget(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
message := strings.ToLower(err.Error())
|
||||
return strings.Contains(message, "invalid target folder") ||
|
||||
strings.Contains(message, "invalid targetfolder")
|
||||
}
|
||||
|
||||
func validCompositeOpaqueID(value string) bool {
|
||||
if value == "" || len(value) > 512 || strings.ContainsAny(value, "/?#") {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if unicode.IsSpace(r) || unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pureNumericID(value string) bool {
|
||||
if value == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range value {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
func executeRecordPrimaryDocGet(rt *shortcut.RuntimeContext) error {
|
||||
baseID, tableID, requestedRecordID := rt.Str("base-id"), rt.Str("table-id"), rt.Str("record-id")
|
||||
fieldsData, err := rt.CallMCPData(serverMain, "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields, found := findNamedObjectList(fieldsData, "fields", "fieldList")
|
||||
if !found {
|
||||
return apperrors.NewAPI("get_fields response is missing the fields collection",
|
||||
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
primaryFieldIDs := make([]string, 0, 1)
|
||||
for index, field := range fields {
|
||||
fieldType := stringValue(field, "type", "fieldType")
|
||||
if !strings.EqualFold(fieldType, "primaryDoc") {
|
||||
continue
|
||||
}
|
||||
fieldID := stringValue(field, "fieldId", "id")
|
||||
if fieldID == "" {
|
||||
return apperrors.NewAPI(fmt.Sprintf("primaryDoc field %d is missing fieldId", index),
|
||||
apperrors.WithOperation("aitable/get_fields"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
primaryFieldIDs = append(primaryFieldIDs, fieldID)
|
||||
}
|
||||
|
||||
recordParams := map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": []string{requestedRecordID},
|
||||
}
|
||||
if len(primaryFieldIDs) > 0 {
|
||||
recordParams["fieldIds"] = primaryFieldIDs
|
||||
}
|
||||
window, err := queryRecordWindow(rt, recordParams, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
records := window.Records
|
||||
if len(records) == 0 {
|
||||
return apperrors.NewAPI(fmt.Sprintf("record %s was not found", requestedRecordID),
|
||||
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("RESOURCE_NOT_FOUND"),
|
||||
apperrors.WithFailureStage("target_resolution"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
if len(records) != 1 || recordID(records[0]) != requestedRecordID {
|
||||
return apperrors.NewAPI("record preflight did not return the exact requested record",
|
||||
apperrors.WithOperation("aitable/query_records"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
if len(primaryFieldIDs) == 0 {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "no_primary_doc_field", "exists": false, "recordId": requestedRecordID, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
data, err := rt.CallMCPData(serverHelper, "get_primary_doc", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordId": requestedRecordID,
|
||||
})
|
||||
if err != nil {
|
||||
if knownPrimaryDocUnassociatedError(err) {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
return err
|
||||
}
|
||||
nodeID := primaryDocNodeID(data)
|
||||
if nodeID == "" {
|
||||
if knownPrimaryDocUnassociatedData(data) {
|
||||
return rt.Output(map[string]any{
|
||||
"status": "unassociated", "exists": false, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nil,
|
||||
})
|
||||
}
|
||||
return apperrors.NewAPI("get_primary_doc response is missing nodeId",
|
||||
apperrors.WithOperation("aitable-helper/get_primary_doc"), apperrors.WithReason("target_invalid_response"),
|
||||
apperrors.WithFailureStage("response_validation"), apperrors.WithExecutionStarted(false))
|
||||
}
|
||||
return rt.Output(map[string]any{
|
||||
"status": "associated", "exists": true, "recordId": requestedRecordID, "fieldIds": primaryFieldIDs, "nodeId": nodeID,
|
||||
})
|
||||
}
|
||||
|
||||
func knownPrimaryDocUnassociatedError(err error) bool {
|
||||
message := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(message, "no record") || strings.Contains(message, "unassociated")
|
||||
}
|
||||
|
||||
func primaryDocNodeID(value any) string {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, key := range []string{"nodeId", "dentryUuid"} {
|
||||
if nodeID, ok := object[key].(string); ok && strings.TrimSpace(nodeID) != "" {
|
||||
return strings.TrimSpace(nodeID)
|
||||
}
|
||||
}
|
||||
for _, envelopeKey := range []string{"data", "result", "response"} {
|
||||
if nodeID := primaryDocNodeID(object[envelopeKey]); nodeID != "" {
|
||||
return nodeID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func knownPrimaryDocUnassociatedData(value any) bool {
|
||||
object, ok := value.(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if exists, ok := object["exists"].(bool); ok && !exists {
|
||||
return true
|
||||
}
|
||||
for _, key := range []string{"nodeId", "dentryUuid"} {
|
||||
if nodeID, exists := object[key]; exists && (nodeID == nil || strings.TrimSpace(fmt.Sprint(nodeID)) == "") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if status, ok := object["status"].(string); ok {
|
||||
status = strings.ToLower(strings.TrimSpace(status))
|
||||
if status == "unassociated" || status == "no_record" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, envelopeKey := range []string{"data", "result", "response"} {
|
||||
if child, ok := object[envelopeKey].(map[string]any); ok && knownPrimaryDocUnassociatedData(child) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(ids); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(ids))
|
||||
batch := ids[offset:end]
|
||||
existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
existingSet := make(map[string]bool, len(existingIDs))
|
||||
for _, id := range existingIDs {
|
||||
existingSet[id] = true
|
||||
}
|
||||
missingIDs := make([]string, 0, len(ids)-len(existingIDs))
|
||||
for _, id := range ids {
|
||||
if !existingSet[id] {
|
||||
missingIDs = append(missingIDs, id)
|
||||
}
|
||||
}
|
||||
result.Resolved = map[string]any{
|
||||
"existingRecordIds": existingIDs,
|
||||
"missingRecordIds": missingIDs,
|
||||
}
|
||||
result.Plan = nil
|
||||
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(existingIDs))
|
||||
result.Plan = append(result.Plan, compositeStep{
|
||||
Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
|
||||
Status: "planned", Offset: offset, Count: end - offset,
|
||||
})
|
||||
}
|
||||
if len(existingIDs) == 0 {
|
||||
result.Status = "unchanged"
|
||||
result.Executed = false
|
||||
result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
|
||||
result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
|
||||
end := minInt(offset+recordBatchSize, len(existingIDs))
|
||||
batch := existingIDs[offset:end]
|
||||
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
|
||||
"baseId": baseID, "tableId": tableID, "recordIds": batch,
|
||||
})
|
||||
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
step.Status = "unknown"
|
||||
result.CompletedSteps = append(result.CompletedSteps, step)
|
||||
result.CompletedCount = offset
|
||||
result.FailedCount = len(ids) - offset
|
||||
result.FailedCount = len(existingIDs) - offset
|
||||
result.Status = "unknown"
|
||||
if offset > 0 {
|
||||
result.Status = "partial_success"
|
||||
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
|
||||
result.CompletedCount = end
|
||||
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
|
||||
}
|
||||
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
|
||||
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
|
||||
result.Verification = map[string]any{
|
||||
"status": "verified_absent", "verifiedCount": len(existingIDs),
|
||||
"missingBeforeWriteCount": len(missingIDs),
|
||||
}
|
||||
result.Result = map[string]any{
|
||||
"deletedCount": len(existingIDs), "missingCount": len(missingIDs),
|
||||
"batchCount": len(result.CompletedSteps),
|
||||
}
|
||||
return rt.Output(result)
|
||||
}
|
||||
|
||||
func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
|
||||
records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wanted := make(map[string]bool, len(ids))
|
||||
for _, id := range ids {
|
||||
wanted[id] = true
|
||||
}
|
||||
present := make(map[string]bool, len(records))
|
||||
for index, record := range records {
|
||||
id := recordID(record)
|
||||
if id == "" {
|
||||
return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
|
||||
}
|
||||
if !wanted[id] {
|
||||
return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
|
||||
}
|
||||
if present[id] {
|
||||
return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
|
||||
}
|
||||
present[id] = true
|
||||
}
|
||||
existing := make([]string, 0, len(present))
|
||||
for _, id := range ids {
|
||||
if present[id] {
|
||||
existing = append(existing, id)
|
||||
}
|
||||
}
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
func parseRecordIDs(values []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
ids := make([]string, 0, len(values))
|
||||
|
||||
@@ -4,11 +4,14 @@
|
||||
package aitable
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
|
||||
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
|
||||
t.Run("record not found is classified", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
|
||||
var typed *apperrors.Error
|
||||
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
|
||||
t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("table without primary doc field is normalized", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
|
||||
t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
|
||||
{text: `{"data":{"nodeId":null}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
|
||||
t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("known helper no-record error is unassociated", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
|
||||
{err: errors.New("no record")},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
|
||||
t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("associated doc is resolved through helper", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
|
||||
{text: `{"data":{"nodeId":"node-1"}}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
|
||||
t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
steps []upsertByKeyStep
|
||||
}{
|
||||
{name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
|
||||
{name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
|
||||
{name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
|
||||
{name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
|
||||
{name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
|
||||
{name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
|
||||
{name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("primary doc failure = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, value := range []any{
|
||||
map[string]any{"exists": false},
|
||||
map[string]any{"dentryUuid": ""},
|
||||
map[string]any{"status": "NO_RECORD"},
|
||||
map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
|
||||
} {
|
||||
if !knownPrimaryDocUnassociatedData(value) {
|
||||
t.Errorf("known unassociated shape not recognized: %#v", value)
|
||||
}
|
||||
}
|
||||
if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
|
||||
t.Fatal("unrelated shapes must not be unassociated")
|
||||
}
|
||||
for _, value := range []any{
|
||||
map[string]any{"metadata": map[string]any{"exists": false}},
|
||||
map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
|
||||
} {
|
||||
if knownPrimaryDocUnassociatedData(value) {
|
||||
t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
|
||||
}
|
||||
}
|
||||
if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
|
||||
t.Fatalf("unrelated nested nodeId = %q, want empty", got)
|
||||
}
|
||||
if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
|
||||
t.Fatalf("known envelope nodeId = %q, want node-1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
records string
|
||||
}{
|
||||
{name: "missing id", records: `[{"cells":{}}]`},
|
||||
{name: "unexpected id", records: `[{"recordId":"other"}]`},
|
||||
{name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != 1 {
|
||||
t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
|
||||
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}
|
||||
|
||||
|
||||
@@ -612,11 +612,18 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to absence", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, args map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
if verificationPage == 1 {
|
||||
if _, exists := args["cursor"]; exists {
|
||||
t.Fatalf("first deletion readback unexpectedly has cursor: %#v", args)
|
||||
}
|
||||
@@ -628,23 +635,30 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
return `{"success":true,"data":{"records":[],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || out == "" || len(caller.calls) != 3 {
|
||||
if err != nil || out == "" || len(caller.calls) != 4 {
|
||||
t.Fatalf("delete continued absence output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("delete readback follows active continuation to remaining record", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
if call == 1 {
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
if verificationPage == 1 {
|
||||
return `{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c1"}}`, nil
|
||||
}
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
if err == nil || out != "" || len(caller.calls) != 4 {
|
||||
t.Fatalf("delete continued remaining output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
@@ -654,14 +668,21 @@ func TestCrossPlatformCoverageRecordQueryFailureAndContinuationBranches(t *testi
|
||||
})
|
||||
|
||||
t.Run("delete readback fails closed on empty continuation stall", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{callFn: func(call int, _, tool string, _ map[string]any) (string, error) {
|
||||
written := false
|
||||
verificationPage := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, _ map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
written = true
|
||||
return `{"deletedCount":1}`, nil
|
||||
}
|
||||
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, call), nil
|
||||
if !written {
|
||||
return `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`, nil
|
||||
}
|
||||
verificationPage++
|
||||
return fmt.Sprintf(`{"success":true,"data":{"records":[],"hasMore":true,"nextCursor":"c%d"}}`, verificationPage), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+1 {
|
||||
if err == nil || out != "" || len(caller.calls) != recordQueryMaxConsecutiveEmptyPages+2 {
|
||||
t.Fatalf("delete stalled continuation output=%q error=%v calls=%#v", out, err, caller.calls)
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
@@ -678,21 +699,26 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
|
||||
records := updateFixtureRecords(0, size, "完成")
|
||||
caller := &upsertByKeyCaller{}
|
||||
queryCalls := 0
|
||||
writeSeen := false
|
||||
caller.callFn = func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool != "query_records" {
|
||||
writeSeen = true
|
||||
return `{"success":true}`, nil
|
||||
}
|
||||
queryCalls++
|
||||
if operation == "delete" {
|
||||
ids := args["recordIds"].([]string)
|
||||
empty := make([]map[string]any, len(ids))
|
||||
response := pagedRecordQueryResponse(t, empty, args)
|
||||
requested := make([]map[string]any, 0, len(ids))
|
||||
if !writeSeen {
|
||||
for _, id := range ids {
|
||||
requested = append(requested, map[string]any{"recordId": id})
|
||||
}
|
||||
}
|
||||
response := pagedRecordQueryResponse(t, requested, args)
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(response), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
page := payload["data"].(map[string]any)
|
||||
page["records"] = []any{}
|
||||
raw, _ := json.Marshal(payload)
|
||||
return string(raw), nil
|
||||
}
|
||||
@@ -725,6 +751,9 @@ func TestCrossPlatformCoverageRecordWriteReadbackUsesStableServicePagesE2E(t *te
|
||||
t.Fatalf("%s size %d false negative: output=%q err=%v", operation, size, out, err)
|
||||
}
|
||||
wantQueries := (size + recordQueryServicePageSize - 1) / recordQueryServicePageSize
|
||||
if operation == "delete" {
|
||||
wantQueries *= 2
|
||||
}
|
||||
if queryCalls != wantQueries {
|
||||
t.Fatalf("%s size %d query calls=%d, want %d", operation, size, queryCalls, wantQueries)
|
||||
}
|
||||
@@ -860,15 +889,20 @@ func recordIDFixtures(count int) []string {
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"data":{"records":[]}}`},
|
||||
writes := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool == "delete_records" {
|
||||
writes++
|
||||
return fmt.Sprintf(`{"deletedCount":%d}`, len(args["recordIds"].([]string))), nil
|
||||
}
|
||||
requested := args["recordIds"].([]string)
|
||||
records := make([]map[string]any, 0, len(requested))
|
||||
if writes == 0 {
|
||||
for _, id := range requested {
|
||||
records = append(records, map[string]any{"recordId": id})
|
||||
}
|
||||
}
|
||||
return recordListJSON(t, records), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err != nil {
|
||||
@@ -879,14 +913,43 @@ func TestCrossPlatformCoverageRecordDeleteAutoChunksAndProvesAbsenceE2E(t *testi
|
||||
t.Fatalf("delete output missing %s: %s", want, out)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 8 || caller.calls[0].tool != "delete_records" || caller.calls[1].tool != "query_records" || caller.calls[6].tool != "delete_records" {
|
||||
if len(caller.calls) != 14 || caller.calls[0].tool != "query_records" || caller.calls[6].tool != "delete_records" || caller.calls[12].tool != "delete_records" {
|
||||
t.Fatalf("delete call sequence = %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteDoesNotCountMissingIDsE2E(t *testing.T) {
|
||||
t.Run("all requested IDs were already absent", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"success":true,"data":{"records":[],"hasMore":false}}`}}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"missing-1", "missing-2"})
|
||||
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "query_records" ||
|
||||
!strings.Contains(out, `"deletedCount": 0`) || !strings.Contains(out, `"missingCount": 2`) {
|
||||
t.Fatalf("already absent delete = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("only records proven present are counted", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"success":true,"data":{"records":[{"recordId":"r1"}],"hasMore":false}}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"success":true,"data":{"records":[],"hasMore":false}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"missing", "r1"})
|
||||
if err != nil || !strings.Contains(out, `"deletedCount": 1`) || !strings.Contains(out, `"missingCount": 1`) {
|
||||
t.Fatalf("mixed delete = output:%q err:%v", out, err)
|
||||
}
|
||||
deleted := caller.calls[1].args["recordIds"].([]string)
|
||||
if !slices.Equal(deleted, []string{"r1"}) {
|
||||
t.Fatalf("delete target IDs = %#v", deleted)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t *testing.T) {
|
||||
t.Run("legal empty collection proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: ""}, {text: `{"records":[]}`}}}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`}, {text: ""}, {text: `{"records":[]}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err != nil || !strings.Contains(out, `"status": "recovered"`) {
|
||||
t.Fatalf("delete recovered = output:%q err:%v", out, err)
|
||||
@@ -895,6 +958,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
t.Run("explicit service success with empty data proves deletion", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`},
|
||||
{text: `{"deletedCount":1}`},
|
||||
{text: `{"success":true,"status":"success","error":{},"data":{}}`},
|
||||
}}
|
||||
@@ -905,7 +969,9 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
})
|
||||
|
||||
t.Run("missing records contract is unknown", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"deletedCount":1}`}, {text: `{"data":{}}`}}}
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"deletedCount":1}`}, {text: `{"data":{}}`},
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
|
||||
if err == nil || out != "" {
|
||||
t.Fatalf("delete missing records = output:%q err:%v", out, err)
|
||||
@@ -918,6 +984,7 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
t.Run("remaining record is not success", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"records":[{"recordId":"r1"}]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r1","cells":{"fld":"still here"}}]}`},
|
||||
}}
|
||||
@@ -930,15 +997,22 @@ func TestCrossPlatformCoverageRecordDeleteEmptyReplyRecoveredOnlyByAbsenceE2E(t
|
||||
|
||||
func TestCrossPlatformCoverageRecordDeletePartialCheckpointE2E(t *testing.T) {
|
||||
ids := recordIDFixtures(101)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"deletedCount":100}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"records":[]}`},
|
||||
{text: `{"deletedCount":0}`},
|
||||
{text: `{"records":[{"recordId":"r100","cells":{}}]}`},
|
||||
writes := 0
|
||||
caller := &upsertByKeyCaller{callFn: func(_ int, _, tool string, args map[string]any) (string, error) {
|
||||
if tool == "delete_records" {
|
||||
writes++
|
||||
return `{"deletedCount":0}`, nil
|
||||
}
|
||||
requested := args["recordIds"].([]string)
|
||||
records := make([]map[string]any, 0, len(requested))
|
||||
if writes == 0 {
|
||||
for _, id := range requested {
|
||||
records = append(records, map[string]any{"recordId": id})
|
||||
}
|
||||
} else if writes == 2 {
|
||||
records = append(records, map[string]any{"recordId": "r100", "cells": map[string]any{}})
|
||||
}
|
||||
return recordListJSON(t, records), nil
|
||||
}}
|
||||
out, err := runRecordDeleteCLI(t, caller, ids)
|
||||
if err == nil || out != "" {
|
||||
|
||||
@@ -77,6 +77,10 @@ func TestCrossPlatformCoverageAITableProjectionValidAndMissingIdentityShapes(t *
|
||||
if err != nil || len(workflows) != 1 {
|
||||
t.Fatalf("workflow projection = %#v, %v", workflows, err)
|
||||
}
|
||||
workflows, err = workflowListProject(map[string]any{"list": []any{map[string]any{"flowId": "flow", "name": "real service shape"}}})
|
||||
if err != nil || len(workflows) != 1 || workflows[0]["workflowId"] != "flow" {
|
||||
t.Fatalf("workflow flowId projection = %#v, %v", workflows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompositeAndReviewedContractFallbacks(t *testing.T) {
|
||||
@@ -183,6 +187,7 @@ func TestCrossPlatformCoverageViewPresetValidationAndDryRunE2E(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackFailuresAndUpdateE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
preflight := `{"views":[{"viewId":"v","viewName":"X","viewType":"Grid","config":{"f":0}}]}`
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -237,6 +242,40 @@ func TestCrossPlatformCoverageViewPresetPureMatching(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetProjectionShapeEdges(t *testing.T) {
|
||||
if !presetViewMatches(map[string]any{"viewType": "Grid", "f": 1}, "Grid", map[string]any{"f": 1}) {
|
||||
t.Fatal("top-level projected config must match")
|
||||
}
|
||||
invalid := []map[string]any{
|
||||
{"columns": "bad", "custom": map[string]any{}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{"f": "bad"}}},
|
||||
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": map[string]any{"1": false}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": map[string]any{}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{}}},
|
||||
{"columns": []any{1}, "custom": map[string]any{"hiddenFields": []any{false}}},
|
||||
{"columns": []any{"f"}, "custom": map[string]any{"hiddenFields": []any{"bad"}}},
|
||||
}
|
||||
for _, view := range invalid {
|
||||
if got, ok := projectedVisibleFieldIDs(view); ok || got != nil {
|
||||
t.Errorf("invalid projection %#v = %#v, %v", view, got, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetReadBackDuplicateStopsE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"viewId":"v1"}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"f":1}`, "--yes")
|
||||
if err == nil || out != "" || len(caller.calls) != 3 {
|
||||
t.Fatalf("duplicate readback = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWorkflowDeployValidationAndFailureStagesE2E(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
|
||||
@@ -7,12 +7,17 @@ import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
)
|
||||
|
||||
const viewPresetReadbackAttempts = 8
|
||||
|
||||
var viewPresetSleep = time.Sleep
|
||||
|
||||
var ViewPresetApply = shortcut.Shortcut{
|
||||
Service: "aitable",
|
||||
Command: "+view-preset-apply",
|
||||
@@ -100,23 +105,39 @@ func executeViewPresetApply(rt *shortcut.RuntimeContext) error {
|
||||
if action == "create" {
|
||||
viewID = findStringByKeys(writeData, "viewId")
|
||||
}
|
||||
readBack, verifyErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
|
||||
if verifyErr == nil && !found {
|
||||
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
|
||||
}
|
||||
verifiedMatches := viewsByExactName(verifiedViews, name)
|
||||
if verifyErr == nil && len(verifiedMatches) != 1 {
|
||||
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
|
||||
}
|
||||
if verifyErr == nil {
|
||||
actualID := stringValue(verifiedMatches[0], "viewId", "id")
|
||||
if actualID == "" || (viewID != "" && actualID != viewID) {
|
||||
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
|
||||
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
|
||||
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
|
||||
} else {
|
||||
viewID = actualID
|
||||
var verifiedMatches []map[string]any
|
||||
var verifyErr error
|
||||
for attempt := 0; attempt < viewPresetReadbackAttempts; attempt++ {
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second
|
||||
if backoff > 12*time.Second {
|
||||
backoff = 12 * time.Second
|
||||
}
|
||||
viewPresetSleep(backoff)
|
||||
}
|
||||
readBack, readErr := rt.CallMCPData(serverMain, "get_views", map[string]any{"baseId": baseID, "tableId": tableID})
|
||||
verifyErr = readErr
|
||||
verifiedViews, found := findNamedObjectList(readBack, "views", "viewList")
|
||||
if verifyErr == nil && !found {
|
||||
verifyErr = fmt.Errorf("get_views read-back is missing the views collection")
|
||||
}
|
||||
verifiedMatches = viewsByExactName(verifiedViews, name)
|
||||
if verifyErr == nil && len(verifiedMatches) != 1 {
|
||||
verifyErr = fmt.Errorf("view read-back matched %d exact-name views, want 1", len(verifiedMatches))
|
||||
if len(verifiedMatches) > 1 {
|
||||
break
|
||||
}
|
||||
}
|
||||
if verifyErr == nil {
|
||||
actualID := stringValue(verifiedMatches[0], "viewId", "id")
|
||||
if actualID == "" || (viewID != "" && actualID != viewID) {
|
||||
verifyErr = fmt.Errorf("view read-back identity mismatch: got %q, response %q", actualID, viewID)
|
||||
} else if !presetViewMatches(verifiedMatches[0], viewType, config) {
|
||||
verifyErr = fmt.Errorf("view read-back does not contain the declared type/config")
|
||||
} else {
|
||||
viewID = actualID
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if verifyErr != nil {
|
||||
@@ -153,8 +174,71 @@ func presetViewMatches(view map[string]any, viewType string, config map[string]a
|
||||
if actualType != "" && actualType != viewType {
|
||||
return false
|
||||
}
|
||||
actualConfig, ok := view["config"].(map[string]any)
|
||||
return ok && mapContains(actualConfig, config)
|
||||
actualConfig := make(map[string]any, len(config))
|
||||
if nested, ok := view["config"].(map[string]any); ok {
|
||||
for key, value := range nested {
|
||||
actualConfig[key] = value
|
||||
}
|
||||
}
|
||||
for key := range config {
|
||||
if value, ok := view[key]; ok {
|
||||
actualConfig[key] = value
|
||||
}
|
||||
}
|
||||
if _, wanted := config["visibleFieldIds"]; wanted {
|
||||
if visible, ok := projectedVisibleFieldIDs(view); ok {
|
||||
actualConfig["visibleFieldIds"] = visible
|
||||
}
|
||||
}
|
||||
return mapContains(actualConfig, config)
|
||||
}
|
||||
|
||||
// get_views projects visible fields as columns plus hiddenFields instead of
|
||||
// echoing create_view's config.visibleFieldIds input. Different deployments
|
||||
// return hiddenFields either as a fieldId-keyed object or a parallel array.
|
||||
func projectedVisibleFieldIDs(view map[string]any) ([]any, bool) {
|
||||
columns, columnsOK := view["columns"].([]any)
|
||||
custom, customOK := view["custom"].(map[string]any)
|
||||
if !columnsOK || !customOK {
|
||||
return nil, false
|
||||
}
|
||||
if hidden, ok := custom["hiddenFields"].(map[string]any); ok {
|
||||
visible := make([]any, 0, len(columns))
|
||||
for _, column := range columns {
|
||||
fieldID, fieldOK := column.(string)
|
||||
if !fieldOK {
|
||||
return nil, false
|
||||
}
|
||||
isHidden, exists := hidden[fieldID]
|
||||
if !exists {
|
||||
return nil, false
|
||||
}
|
||||
hiddenFlag, hiddenTypeOK := isHidden.(bool)
|
||||
if !hiddenTypeOK {
|
||||
return nil, false
|
||||
}
|
||||
if !hiddenFlag {
|
||||
visible = append(visible, fieldID)
|
||||
}
|
||||
}
|
||||
return visible, true
|
||||
}
|
||||
hidden, hiddenOK := custom["hiddenFields"].([]any)
|
||||
if !hiddenOK || len(columns) != len(hidden) {
|
||||
return nil, false
|
||||
}
|
||||
visible := make([]any, 0, len(columns))
|
||||
for index, column := range columns {
|
||||
fieldID, fieldOK := column.(string)
|
||||
isHidden, hiddenTypeOK := hidden[index].(bool)
|
||||
if !fieldOK || !hiddenTypeOK {
|
||||
return nil, false
|
||||
}
|
||||
if !isHidden {
|
||||
visible = append(visible, fieldID)
|
||||
}
|
||||
}
|
||||
return visible, true
|
||||
}
|
||||
|
||||
func mapContains(actual, expected map[string]any) bool {
|
||||
|
||||
@@ -7,13 +7,21 @@ import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
const workflowDSLFixture = `{"version":"workflow-dsl/v1","name":"提醒","nodes":[]}`
|
||||
|
||||
func disableViewPresetSleep(t *testing.T) {
|
||||
t.Helper()
|
||||
testseam.Swap(t, &viewPresetSleep, func(time.Duration) {})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
t.Run("create and verify exact config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
@@ -30,6 +38,32 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projected columns and hidden flags verify visible field config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"投影","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":[false,true,false]}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "投影", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1","f3"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) {
|
||||
t.Fatalf("projected view preset = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("projected columns and keyed hidden flags verify visible field config", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
{text: `{"views":[{"viewId":"v1","viewName":"投影对象","viewType":"Grid","columns":["f1","f2","f3"],"custom":{"hiddenFields":{"f1":true,"f2":false,"f3":false}}}]}`},
|
||||
}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "投影对象", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f2","f3"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) {
|
||||
t.Fatalf("keyed projected view preset = output:%q err:%v", out, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unchanged preset makes no write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"待处理","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
@@ -52,7 +86,26 @@ func TestCrossPlatformCoverageViewPresetCreateUpdateAndVerificationE2E(t *testin
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetRetriesUntilTerminalStateE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
steps := []upsertByKeyStep{
|
||||
{text: `{"views":[]}`},
|
||||
{text: `{"data":{"viewId":"v1"}}`},
|
||||
}
|
||||
for attempt := 0; attempt < viewPresetReadbackAttempts-1; attempt++ {
|
||||
steps = append(steps, upsertByKeyStep{text: `{"views":[]}`})
|
||||
}
|
||||
steps = append(steps, upsertByKeyStep{text: `{"views":[{"viewId":"v1","viewName":"X","viewType":"Grid","config":{"visibleFieldIds":["f1"]}}]}`})
|
||||
caller := &upsertByKeyCaller{steps: steps}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
"--base-id", "base", "--table-id", "table", "--name", "X", "--view-type", "Grid", "--config", `{"visibleFieldIds":["f1"]}`, "--yes")
|
||||
if err != nil || !strings.Contains(out, `"status": "verified"`) || len(caller.calls) != viewPresetReadbackAttempts+2 {
|
||||
t.Fatalf("eventual view preset = output:%q err:%v calls:%#v", out, err, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageViewPresetAmbiguousOrMismatchedIsNotSuccessE2E(t *testing.T) {
|
||||
disableViewPresetSleep(t)
|
||||
t.Run("duplicate name stops before write", func(t *testing.T) {
|
||||
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"views":[{"viewId":"v1","viewName":"X"},{"viewId":"v2","viewName":"X"}]}`}}}
|
||||
out, err := runAITableCompositeCLI(t, caller, "+view-preset-apply",
|
||||
|
||||
@@ -219,6 +219,9 @@ func ResolveBaseName(reader Reader, name string, allowFuzzy bool) (Resolution, e
|
||||
all = append(all, Candidate{ID: id, Name: candidateName})
|
||||
}
|
||||
next, hasMore, hasMoreKnown := pagination(data)
|
||||
if hasMoreKnown && !hasMore {
|
||||
return selectCandidate("base", query, dedupe(all), allowFuzzy)
|
||||
}
|
||||
if next == "" {
|
||||
if hasMoreKnown && hasMore {
|
||||
return Resolution{}, incomplete("base", query, all, "search_bases 声明有后续页但没有 cursor")
|
||||
|
||||
@@ -125,6 +125,24 @@ func TestCrossPlatformCoverageResolveBaseNameExactPaginationE2E(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveBaseStopsOnAuthoritativeTerminalCursor(t *testing.T) {
|
||||
reader := &resolverReader{steps: []resolverStep{
|
||||
{data: map[string]any{"bases": []any{}, "hasMore": true, "nextCursor": "next"}},
|
||||
{data: map[string]any{
|
||||
"bases": []any{map[string]any{"baseId": "b1", "baseName": "项目管理"}},
|
||||
"hasMore": false,
|
||||
"nextCursor": "next",
|
||||
}},
|
||||
}}
|
||||
got, err := ResolveBaseName(reader, "项目管理", false)
|
||||
if err != nil || got.Selected.ID != "b1" {
|
||||
t.Fatalf("terminal cursor resolution = %#v, %v", got, err)
|
||||
}
|
||||
if len(reader.calls) != 2 {
|
||||
t.Fatalf("terminal hasMore=false made %d calls, want 2", len(reader.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageResolveNameFuzzyIsExplicitAndAmbiguityFails(t *testing.T) {
|
||||
data := map[string]any{"bases": []any{
|
||||
map[string]any{"baseId": "b1", "baseName": "项目管理归档"},
|
||||
|
||||
@@ -162,8 +162,13 @@ var SearchUser = shortcut.Shortcut{
|
||||
// the clean output projection applied to every list command.
|
||||
// Field names are probed defensively across candidate keys.
|
||||
func searchUserProject(data map[string]any) []map[string]any {
|
||||
raw, ok := data["result"].([]any)
|
||||
if !ok {
|
||||
var raw []any
|
||||
switch result := data["result"].(type) {
|
||||
case []any:
|
||||
raw = result
|
||||
case map[string]any:
|
||||
raw = []any{result}
|
||||
default:
|
||||
return []map[string]any{}
|
||||
}
|
||||
out := make([]map[string]any, 0, len(raw))
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package contact
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobileProjectsObjectEnvelope(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{
|
||||
"name": "Alice",
|
||||
"userId": "u1",
|
||||
"flowerName": "A",
|
||||
"openDingTalkId": "open-1",
|
||||
"title": "Engineer",
|
||||
"unrelatedPrivate": "drop-me",
|
||||
},
|
||||
})
|
||||
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
|
||||
t.Fatalf("object envelope projection = %#v, want one user", users)
|
||||
}
|
||||
if _, exists := users[0]["unrelatedPrivate"]; exists {
|
||||
t.Fatalf("projection leaked unrelated field: %#v", users[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobilePreservesEmptyObjectSemantics(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{"success": true, "result": map[string]any{}})
|
||||
if len(users) != 0 {
|
||||
t.Fatalf("empty result projected users: %#v", users)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSearchMobileProjectsArrayAndMissingResult(t *testing.T) {
|
||||
users := searchUserProject(map[string]any{
|
||||
"result": []any{map[string]any{"name": "Alice", "userId": "u1"}},
|
||||
})
|
||||
if len(users) != 1 || users[0]["userId"] != "u1" || users[0]["name"] != "Alice" {
|
||||
t.Fatalf("array envelope projection = %#v, want one user", users)
|
||||
}
|
||||
if users := searchUserProject(map[string]any{"success": true}); len(users) != 0 {
|
||||
t.Fatalf("missing result projected users: %#v", users)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@ func RequireWriteAcknowledgement(operation string, data map[string]any) error {
|
||||
// RequirePermissionMutationAcknowledgement validates the target-level payload
|
||||
// returned by add/remove_member_permission. The backend's top-level
|
||||
// success=true is not sufficient: historically it also accompanied a
|
||||
// resultMap that merely echoed nonexistent task UUIDs.
|
||||
// resultMap that merely echoed nonexistent task UUIDs, and it does not prove
|
||||
// that every requested task/member pair was changed.
|
||||
func RequirePermissionMutationAcknowledgement(operation string, taskUUIDs, memberUIDs []string, data map[string]any) error {
|
||||
if err := validateEnvelope(data); err != nil {
|
||||
return err
|
||||
|
||||
@@ -26,6 +26,26 @@ func TestCrossPlatformCoverageMinutesWorkflowShapesRejectUnknownE2E(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesPermissionMutationRequiresExactResultMap(t *testing.T) {
|
||||
for _, data := range []map[string]any{
|
||||
{"success": true, "result": map[string]any{}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"u1": []any{}}}},
|
||||
{"success": true, "result": map[string]any{"resultMap": map[string]any{"other": []any{"m1"}}}},
|
||||
} {
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, data); err == nil {
|
||||
t.Fatalf("incomplete permission acknowledgement accepted: %#v", data)
|
||||
}
|
||||
}
|
||||
valid := map[string]any{
|
||||
"success": true,
|
||||
"result": map[string]any{"resultMap": map[string]any{"u1": []any{"m1"}}},
|
||||
}
|
||||
if err := RequirePermissionMutationAcknowledgement("unshare", []string{"u1"}, []string{"m1"}, valid); err != nil {
|
||||
t.Fatalf("valid permission acknowledgement rejected: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMinutesWorkflowObservedShapesE2E(t *testing.T) {
|
||||
record, err := RecordResult("pause", "u1", map[string]any{"success": true, "result": map[string]any{"cmd": "pause", "uuid": "u1"}})
|
||||
if err != nil || record["cmd"] != "pause" {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package todo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type todoPageSizeCaller struct {
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *todoPageSizeCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls++
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true,"result":{"todoCards":[]}}`}}}, nil
|
||||
}
|
||||
|
||||
func (*todoPageSizeCaller) Format() string { return "json" }
|
||||
func (*todoPageSizeCaller) DryRun() bool { return false }
|
||||
func (*todoPageSizeCaller) Fields() string { return "" }
|
||||
func (*todoPageSizeCaller) JQ() string { return "" }
|
||||
|
||||
func TestCrossPlatformCoverageGetMyTasksRejectsUnsupportedPageSize(t *testing.T) {
|
||||
caller := &todoPageSizeCaller{}
|
||||
helpers.InitDepsForTest(t, caller)
|
||||
root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true}
|
||||
root.PersistentFlags().Bool("yes", false, "")
|
||||
root.PersistentFlags().Bool("dry-run", false, "")
|
||||
root.PersistentFlags().String("format", "json", "")
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.AddCommand(shortcut.Commands()...)
|
||||
root.SetArgs([]string{"todo", "+get-my-tasks", "--size", "21"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "--size") {
|
||||
t.Fatalf("size 21 error = %v, want local --size validation", err)
|
||||
}
|
||||
if caller.calls != 0 {
|
||||
t.Fatalf("size 21 made %d remote calls, want 0", caller.calls)
|
||||
}
|
||||
}
|
||||
@@ -154,6 +154,30 @@ func projectWikiRows(items []any, aliases map[string][]string) []map[string]any
|
||||
return rows
|
||||
}
|
||||
|
||||
func projectWikiRowsPreservingSource(items []any, aliases map[string][]string) []map[string]any {
|
||||
rows := make([]map[string]any, 0, len(items))
|
||||
for _, item := range items {
|
||||
source := item.(map[string]any)
|
||||
row := make(map[string]any, len(source)+len(aliases))
|
||||
for key, value := range source {
|
||||
row[key] = value
|
||||
}
|
||||
for canonical, candidates := range aliases {
|
||||
if value, ok := row[canonical]; ok && value != nil {
|
||||
continue
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if value, ok := source[candidate]; ok && value != nil {
|
||||
row[canonical] = value
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
rows = append(rows, row)
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
func addWikiPagination(out, page map[string]any) {
|
||||
for _, pair := range [][2]string{{"nextCursor", "nextCursor"}, {"nextToken", "nextCursor"}, {"nextPageToken", "nextCursor"}, {"pageToken", "nextCursor"}, {"hasMore", "hasMore"}, {"truncated", "truncated"}, {"totalCount", "totalCount"}, {"autoPageComplete", "autoPageComplete"}, {"autoPageStopReason", "autoPageStopReason"}, {"pagesFetched", "pagesFetched"}} {
|
||||
if value, ok := page[pair[0]]; ok && value != nil {
|
||||
|
||||
@@ -237,7 +237,7 @@ var FeedList = readShortcut("+feed-list", "严格分页列出知识库动态", "
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
feeds := projectWikiRows(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
|
||||
feeds := projectWikiRowsPreservingSource(items, map[string][]string{"id": {"id", "feedId"}, "type": {"type", "feedType", "action"}, "time": {"time", "createTime"}, "name": {"name", "title", "fileName"}, "nodeId": {"nodeId", "fileId"}})
|
||||
out := map[string]any{"count": len(feeds), "feeds": feeds}
|
||||
addWikiPagination(out, page)
|
||||
return rt.Output(out)
|
||||
|
||||
@@ -294,6 +294,29 @@ func TestCrossPlatformCoverageWikiReadWorkflows(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiFeedListPreservesBusinessPayload(t *testing.T) {
|
||||
caller := &wikiCoverageCaller{responses: map[string][]string{
|
||||
"wiki/list_workspace_feeds": {`{"success":true,"feeds":[{"type":1,"time":1750067400000,"content":{"doc":{"name":"Important","docKey":"dk1"}},"users":[{"nick":"Alice","userId":"u1"}],"futureField":{"value":"keep"}}],"hasMore":false}`},
|
||||
}}
|
||||
out, err := runWikiCoverageCLI(t, caller, "+feed-list", "--workspace", "w")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
feeds := out["feeds"].([]any)
|
||||
feed := feeds[0].(map[string]any)
|
||||
content, contentOK := feed["content"].(map[string]any)
|
||||
users, usersOK := feed["users"].([]any)
|
||||
if !contentOK || content["doc"].(map[string]any)["name"] != "Important" {
|
||||
t.Fatalf("content was lost or changed: %#v", feed)
|
||||
}
|
||||
if !usersOK || users[0].(map[string]any)["nick"] != "Alice" {
|
||||
t.Fatalf("users were lost or changed: %#v", feed)
|
||||
}
|
||||
if _, ok := feed["futureField"]; !ok {
|
||||
t.Fatalf("unknown feed business fields must be preserved: %#v", feed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWikiWriteWorkflows(t *testing.T) {
|
||||
t.Run("confirmation gates precede every remote call", func(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
|
||||
@@ -403,6 +403,11 @@ CANDIDATE_RAW="$TMP_ROOT/candidate-schema.json"
|
||||
go build -o "$CANDIDATE_BIN" ./cmd
|
||||
)
|
||||
|
||||
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=false
|
||||
if "$CHECKER" --help 2>&1 | grep -Fq -- 'migration-base-schema'; then
|
||||
CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA=true
|
||||
fi
|
||||
|
||||
mkdir -p "$TMP_ROOT/base-home" "$TMP_ROOT/stable-home" "$TMP_ROOT/candidate-home"
|
||||
HOME="$TMP_ROOT/base-home" DWS_LANG=zh \
|
||||
"$BASE_BIN" schema --all --format json >"$BASE_RAW"
|
||||
@@ -468,23 +473,22 @@ check_with_migrations() {
|
||||
historical_kind="$1"
|
||||
historical_ref="$2"
|
||||
historical_baseline="$3"
|
||||
set -- \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
set -- "$@" \
|
||||
--approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
else
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" \
|
||||
--approved-flag-migrations "$APPROVED_MANIFEST" \
|
||||
--candidate-flag-migrations "$CANDIDATE_MANIFEST" \
|
||||
--migration-current-snapshot "$CURRENT_INTERFACE_SNAPSHOT" \
|
||||
--migration-base-snapshot "$BASE_INTERFACE_SNAPSHOT" \
|
||||
--migration-stable-snapshot "$STABLE_INTERFACE_SNAPSHOT"
|
||||
--candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST"
|
||||
fi
|
||||
if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ] &&
|
||||
[ "$CHECKER_SUPPORTS_MIGRATION_BASE_SCHEMA" = true ]; then
|
||||
set -- "$@" --migration-base-schema "$BASELINE"
|
||||
fi
|
||||
check_schema_contract "$historical_kind" "$historical_ref" "$historical_baseline" "$@"
|
||||
}
|
||||
|
||||
check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -233,6 +233,53 @@ func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testin
|
||||
t.Fatalf("constraint drift was hidden: %s", failures)
|
||||
}
|
||||
|
||||
unchangedLegacyConstraints := cloneContract(current)
|
||||
product = unchangedLegacyConstraints.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints
|
||||
product.Tools["chat.move"] = tool
|
||||
unchangedLegacyConstraints.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, unchangedLegacyConstraints, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
|
||||
t.Fatalf("unchanged legacy constraints error=%v", err)
|
||||
}
|
||||
|
||||
mixedLegacyConstraints := cloneContract(current)
|
||||
product = mixedLegacyConstraints.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = `{"require_together":[["keep","new-id","old-id"]]}`
|
||||
product.Tools["chat.move"] = tool
|
||||
mixedLegacyConstraints.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, mixedLegacyConstraints, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), "still reference legacy Schema constraint parameter") {
|
||||
t.Fatalf("mixed legacy constraints error=%v", err)
|
||||
}
|
||||
|
||||
malformedHistoricalConstraints := cloneContract(baseline)
|
||||
product = malformedHistoricalConstraints.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = "{"
|
||||
product.Tools["chat.move"] = tool
|
||||
malformedHistoricalConstraints.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(malformedHistoricalConstraints, current, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), "historical Schema constraints are not canonicalizable") {
|
||||
t.Fatalf("malformed historical constraints error=%v", err)
|
||||
}
|
||||
|
||||
malformedCurrentConstraints := cloneContract(current)
|
||||
product = malformedCurrentConstraints.Products["chat"]
|
||||
tool = product.Tools["chat.move"]
|
||||
tool.Constraints = "{"
|
||||
product.Tools["chat.move"] = tool
|
||||
malformedCurrentConstraints.Products["chat"] = product
|
||||
if _, err := normalizeSchemaCommandMigrations(baseline, malformedCurrentConstraints, migrations); err == nil ||
|
||||
!strings.Contains(err.Error(), "current Schema constraints are not canonicalizable") {
|
||||
t.Fatalf("malformed current constraints error=%v", err)
|
||||
}
|
||||
if source, found := migratedConstraintSourceParameter("{", map[string]string{"legacy": "canonical"}); found || source != "" {
|
||||
t.Fatalf("malformed migrated constraint source = %q, %v", source, found)
|
||||
}
|
||||
|
||||
extractionWrongSource := cloneContract(current)
|
||||
product = extractionWrongSource.Products["chat"]
|
||||
tool = product.Tools["chat.create_group"]
|
||||
@@ -657,6 +704,508 @@ func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationComposesHistoricalFlagLineage(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
stableContract, baseContract, currentContract := schemaCommandLineageContracts()
|
||||
baselinePath := filepath.Join(directory, "stable-schema.json")
|
||||
baseSchemaPath := filepath.Join(directory, "base-schema.json")
|
||||
currentPath := filepath.Join(directory, "current-schema.json")
|
||||
approvedFlagPath := filepath.Join(directory, "approved-flags.json")
|
||||
candidateFlagPath := filepath.Join(directory, "candidate-flags.json")
|
||||
approvedCommandPath := filepath.Join(directory, "approved-commands.json")
|
||||
candidateCommandPath := filepath.Join(directory, "candidate-commands.json")
|
||||
currentSnapshotPath := filepath.Join(directory, "current-snapshot.json")
|
||||
baseSnapshotPath := filepath.Join(directory, "base-snapshot.json")
|
||||
stableSnapshotPath := filepath.Join(directory, "stable-snapshot.json")
|
||||
|
||||
writeSchemaContractFile(t, baselinePath, stableContract)
|
||||
writeSchemaContractFile(t, baseSchemaPath, baseContract)
|
||||
writeRawSchemaContractFile(t, currentPath, currentContract)
|
||||
writeFlagMigrationManifestFile(t, approvedFlagPath, schemaCommandLineageFlagManifest())
|
||||
writeFlagMigrationManifestFile(t, candidateFlagPath, schemaCommandLineageFlagManifest())
|
||||
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending))
|
||||
writeCommandMigrationManifestFile(t, candidateCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandLineageSnapshot(true, true))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, false))
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandLineageSnapshot(false, false))
|
||||
|
||||
args := []string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--migration-base-schema", baseSchemaPath,
|
||||
"--approved-flag-migrations", approvedFlagPath,
|
||||
"--candidate-flag-migrations", candidateFlagPath,
|
||||
"--approved-command-migrations", approvedCommandPath,
|
||||
"--candidate-command-migrations", candidateCommandPath,
|
||||
"--migration-current-snapshot", currentSnapshotPath,
|
||||
"--migration-base-snapshot", baseSnapshotPath,
|
||||
"--migration-stable-snapshot", stableSnapshotPath,
|
||||
}
|
||||
var stdout, stderr strings.Builder
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("pending command lineage code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
withoutBaseSchema := append([]string(nil), args[:4]...)
|
||||
withoutBaseSchema = append(withoutBaseSchema, args[6:]...)
|
||||
stderr.Reset()
|
||||
if code := run(withoutBaseSchema, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "requires --migration-base-schema") {
|
||||
t.Fatalf("missing migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
stderr.Reset()
|
||||
if code := run([]string{
|
||||
"--check", baselinePath,
|
||||
"--current", currentPath,
|
||||
"--migration-base-schema", baseSchemaPath,
|
||||
}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "requires both flag and command") {
|
||||
t.Fatalf("orphan migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
missingBaseSchema := append([]string(nil), args...)
|
||||
missingBaseSchema[5] = filepath.Join(directory, "missing-base-schema.json")
|
||||
stderr.Reset()
|
||||
if code := run(missingBaseSchema, &stdout, &stderr); code != 2 ||
|
||||
!strings.Contains(stderr.String(), "read migration merge-base Schema contract") {
|
||||
t.Fatalf("unreadable migration base Schema code=%d stderr=%q", code, stderr.String())
|
||||
}
|
||||
|
||||
// After the command move is merged, the merge-base Schema is already at the
|
||||
// final name. The two consumed receipts must keep the stable lineage durable
|
||||
// until the stable release itself reaches the after state.
|
||||
writeSchemaContractFile(t, baseSchemaPath, currentContract)
|
||||
writeCommandMigrationManifestFile(t, approvedCommandPath, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed))
|
||||
writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandLineageSnapshot(true, true))
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if code := run(args, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("consumed command lineage code=%d stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationLineageFailsClosed(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
commandState string
|
||||
mutate func(*schemaContract, *schemaContract, *schemaContract, *[]interfacesnapshot.FlagMigration, *[]interfacesnapshot.CommandMigration)
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "pending merge-base missing intermediate",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
delete(tool.Parameters, "conversation-id")
|
||||
})
|
||||
},
|
||||
want: "merge-base Schema lacks intermediate parameter",
|
||||
},
|
||||
{
|
||||
name: "merge-base missing source tool",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
product := base.Products["chat"]
|
||||
delete(product.Tools, "chat.list_topic_replies")
|
||||
base.Products["chat"] = product
|
||||
},
|
||||
want: "merge-base Schema lacks source tool",
|
||||
},
|
||||
{
|
||||
name: "pending merge-base parameter drift",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "conversation-id", func(parameter *parameterSchema) {
|
||||
parameter.Property = "differentProperty"
|
||||
})
|
||||
},
|
||||
want: "changed a non-migration field",
|
||||
},
|
||||
{
|
||||
name: "pending merge-base wrong path",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
})
|
||||
},
|
||||
want: "merge-base Schema source tool has primary_cli_path",
|
||||
},
|
||||
{
|
||||
name: "pending merge-base already publishes final",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["open-topic-id"] = tool.Parameters["conversation-id"]
|
||||
})
|
||||
},
|
||||
want: "already publishes final parameter",
|
||||
},
|
||||
{
|
||||
name: "pending flag receipt",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
(*flags)[0].State = interfacesnapshot.FlagMigrationPending
|
||||
},
|
||||
want: "requires a consumed flag migration receipt",
|
||||
},
|
||||
{
|
||||
name: "flag receipt belongs to another command",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, _, _ *schemaContract, flags *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
(*flags)[0].Command = "dws chat message other"
|
||||
},
|
||||
want: `historical Schema tool lacks parameter "conversation-id"`,
|
||||
},
|
||||
{
|
||||
name: "current retains predecessor",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(stable, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||
})
|
||||
},
|
||||
want: "current Schema still publishes predecessor parameter",
|
||||
},
|
||||
{
|
||||
name: "pending current constraints retain predecessor",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
|
||||
})
|
||||
},
|
||||
want: "current Schema constraints still reference predecessor parameter",
|
||||
},
|
||||
{
|
||||
name: "consumed current constraints retain predecessor",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(_, _, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_together":[["group","open-conv-thread-id","open-topic-id"]]}`
|
||||
})
|
||||
},
|
||||
want: "current Schema constraints still reference predecessor parameter",
|
||||
},
|
||||
{
|
||||
name: "current retains intermediate",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["conversation-id"] = base.Products["chat"].Tools["chat.list_topic_replies"].Parameters["conversation-id"]
|
||||
})
|
||||
},
|
||||
want: `still publishes legacy Schema parameter "conversation-id"`,
|
||||
},
|
||||
{
|
||||
name: "consumed merge-base final drift",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationParameter(base, "chat.list_topic_replies", "open-topic-id", func(parameter *parameterSchema) {
|
||||
parameter.Required = false
|
||||
})
|
||||
},
|
||||
want: "changed a non-name field",
|
||||
},
|
||||
{
|
||||
name: "consumed merge-base wrong path",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
})
|
||||
},
|
||||
want: "consumed command migration",
|
||||
},
|
||||
{
|
||||
name: "consumed merge-base missing final",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
delete(tool.Parameters, "open-topic-id")
|
||||
})
|
||||
},
|
||||
want: "merge-base Schema lacks final parameter",
|
||||
},
|
||||
{
|
||||
name: "consumed merge-base retains intermediate",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["conversation-id"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||
})
|
||||
},
|
||||
want: "still publishes intermediate parameter",
|
||||
},
|
||||
{
|
||||
name: "pending constraints drift",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_one_of":[["conversation-id"]]}`
|
||||
})
|
||||
},
|
||||
want: "changed merge-base Schema constraints",
|
||||
},
|
||||
{
|
||||
name: "pending current keeps intermediate constraints",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, current *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(current, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = base.Products["chat"].Tools["chat.list_topic_replies"].Constraints
|
||||
})
|
||||
},
|
||||
want: "still reference legacy Schema constraint parameter",
|
||||
},
|
||||
{
|
||||
name: "merge-base retains predecessor",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(stable, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["group"] = stable.Products["chat"].Tools["chat.list_topic_replies"].Parameters["group"]
|
||||
})
|
||||
},
|
||||
want: "merge-base Schema still publishes predecessor parameter",
|
||||
},
|
||||
{
|
||||
name: "historical constraints malformed",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = "not-json"
|
||||
})
|
||||
},
|
||||
want: "historical Schema constraints are not canonicalizable",
|
||||
},
|
||||
{
|
||||
name: "merge-base constraints malformed",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = "not-json"
|
||||
})
|
||||
},
|
||||
want: "merge-base Schema constraints are not canonicalizable",
|
||||
},
|
||||
{
|
||||
name: "consumed constraints drift",
|
||||
commandState: interfacesnapshot.CommandMigrationConsumed,
|
||||
mutate: func(_, base, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Constraints = `{"require_one_of":[["open-topic-id"]]}`
|
||||
})
|
||||
},
|
||||
want: "changed merge-base Schema constraints",
|
||||
},
|
||||
{
|
||||
name: "target collision",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
mutateSchemaCommandMigrationTool(stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["open-topic-id"] = tool.Parameters["group"]
|
||||
})
|
||||
},
|
||||
want: "target \"open-topic-id\" already exists",
|
||||
},
|
||||
{
|
||||
name: "lineage cycle",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
|
||||
(*commands)[0].Schema.Parameters[0].To = "group"
|
||||
},
|
||||
want: "lineage cycle",
|
||||
},
|
||||
{
|
||||
name: "unsupported command receipt state",
|
||||
commandState: "unknown",
|
||||
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
},
|
||||
want: "unsupported lineage state",
|
||||
},
|
||||
{
|
||||
name: "duplicate historical path",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(stable, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, _ *[]interfacesnapshot.CommandMigration) {
|
||||
product := stable.Products["chat"]
|
||||
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
|
||||
stable.Products["chat"] = product
|
||||
},
|
||||
want: "matches 2 historical Schema tools",
|
||||
},
|
||||
{
|
||||
name: "source tool fork",
|
||||
commandState: interfacesnapshot.CommandMigrationPending,
|
||||
mutate: func(_, _, _ *schemaContract, _ *[]interfacesnapshot.FlagMigration, commands *[]interfacesnapshot.CommandMigration) {
|
||||
fork := (*commands)[0]
|
||||
fork.Legacy.Command = "dws chat message fork"
|
||||
fork.Replacement.Command = "dws chat topic fork"
|
||||
*commands = append(*commands, fork)
|
||||
},
|
||||
want: "fork Schema source tool",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
stable, baseBefore, current := schemaCommandLineageContracts()
|
||||
base := baseBefore
|
||||
if test.commandState == interfacesnapshot.CommandMigrationConsumed {
|
||||
base = cloneContract(current)
|
||||
}
|
||||
flags := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...)
|
||||
commands := append([]interfacesnapshot.CommandMigration(nil), schemaCommandLineageManifest(test.commandState).Migrations...)
|
||||
test.mutate(&stable, &base, ¤t, &flags, &commands)
|
||||
if _, err := normalizeSchemaCommandMigrationLineage(stable, base, current, flags, commands); err == nil ||
|
||||
!strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("lineage error=%v, want %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationLineagePreservesOrdinaryChecks(t *testing.T) {
|
||||
stable, base, current := schemaCommandLineageContracts()
|
||||
mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Positionals = []positionalSchema{{Name: "open-topic-id", Index: 0, Type: "string", Required: true}}
|
||||
})
|
||||
normalized, err := normalizeSchemaCommandMigrationLineage(
|
||||
stable,
|
||||
base,
|
||||
current,
|
||||
schemaCommandLineageFlagManifest().Migrations,
|
||||
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if failures := strings.Join(checkCompatibility(normalized, current), "\n"); !strings.Contains(failures, "changed positionals") {
|
||||
t.Fatalf("lineage hid positional drift: %s", failures)
|
||||
}
|
||||
|
||||
// Multiple historical names may converge only when every predecessor carries
|
||||
// the exact same contract and every receipt is already consumed.
|
||||
stable, base, current = schemaCommandLineageContracts()
|
||||
mutateSchemaCommandMigrationTool(&stable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["id"] = tool.Parameters["group"]
|
||||
})
|
||||
flags := schemaCommandLineageFlagManifest().Migrations
|
||||
second := flags[0]
|
||||
second.Legacy.Name = "id"
|
||||
flags = append(flags, second)
|
||||
normalized, err = normalizeSchemaCommandMigrationLineage(
|
||||
stable,
|
||||
base,
|
||||
current,
|
||||
flags,
|
||||
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("equivalent predecessor aliases should pass: %v", err)
|
||||
}
|
||||
if failures := checkCompatibility(normalized, current); len(failures) != 0 {
|
||||
t.Fatalf("equivalent predecessor aliases remained incompatible: %v", failures)
|
||||
}
|
||||
|
||||
mutateSchemaCommandMigrationParameter(&stable, "chat.list_topic_replies", "id", func(parameter *parameterSchema) {
|
||||
parameter.Property = "differentProperty"
|
||||
})
|
||||
if _, err := normalizeSchemaCommandMigrationLineage(
|
||||
stable,
|
||||
base,
|
||||
current,
|
||||
flags,
|
||||
schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations,
|
||||
); err == nil || !strings.Contains(err.Error(), "changed a non-migration field") {
|
||||
t.Fatalf("drifted predecessor alias error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSchemaCommandMigrationLineageDefensiveEdges(t *testing.T) {
|
||||
stable, base, current := schemaCommandLineageContracts()
|
||||
flags := schemaCommandLineageFlagManifest().Migrations
|
||||
commands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||
|
||||
flagExtraction := schemaCommandMigrationAuthorizations()[1]
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(stable, base, current, flags, []interfacesnapshot.CommandMigration{flagExtraction}); err != nil {
|
||||
t.Fatalf("non-move command migration should be ignored: %v", err)
|
||||
}
|
||||
|
||||
missingTool := cloneContract(stable)
|
||||
product := missingTool.Products["chat"]
|
||||
delete(product.Tools, "chat.list_topic_replies")
|
||||
missingTool.Products["chat"] = product
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(missingTool, base, current, flags, commands); err != nil {
|
||||
t.Fatalf("missing historical source should remain for the ordinary checker: %v", err)
|
||||
}
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(current, current, current, flags, schemaCommandLineageManifest(interfacesnapshot.CommandMigrationConsumed).Migrations); err != nil {
|
||||
t.Fatalf("already-after historical source should be a no-op: %v", err)
|
||||
}
|
||||
wrongPath := cloneContract(stable)
|
||||
mutateSchemaCommandMigrationTool(&wrongPath, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.PrimaryCLIPath = "chat unrelated"
|
||||
})
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(wrongPath, base, current, flags, commands); err != nil {
|
||||
t.Fatalf("wrong historical path should remain for the command normalizer: %v", err)
|
||||
}
|
||||
|
||||
bothNames := cloneContract(stable)
|
||||
mutateSchemaCommandMigrationTool(&bothNames, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["conversation-id"] = tool.Parameters["group"]
|
||||
})
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(bothNames, base, current, flags, commands); err == nil ||
|
||||
!strings.Contains(err.Error(), "publishes both predecessor") {
|
||||
t.Fatalf("ambiguous predecessor error=%v", err)
|
||||
}
|
||||
|
||||
duplicatePath := cloneContract(stable)
|
||||
product = duplicatePath.Products["chat"]
|
||||
product.Tools["chat.duplicate"] = product.Tools["chat.list_topic_replies"]
|
||||
duplicatePath.Products["chat"] = product
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(duplicatePath, base, current, flags, commands); err == nil ||
|
||||
!strings.Contains(err.Error(), "requires one exact historical Schema tool") {
|
||||
t.Fatalf("duplicate primary path error=%v", err)
|
||||
}
|
||||
|
||||
forkStable, forkBase, forkCurrent := schemaCommandLineageContracts()
|
||||
mutateSchemaCommandMigrationTool(&forkBase, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
|
||||
})
|
||||
mutateSchemaCommandMigrationTool(&forkCurrent, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["other-final"] = tool.Parameters["open-topic-id"]
|
||||
})
|
||||
forkFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
|
||||
forkFlag := flags[0]
|
||||
forkFlag.Canonical.Name = "other-mid"
|
||||
forkFlags = append(forkFlags, forkFlag)
|
||||
forkCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||
forkCommands[0].Schema.Parameters = append(forkCommands[0].Schema.Parameters,
|
||||
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(forkStable, forkBase, forkCurrent, forkFlags, forkCommands); err == nil ||
|
||||
!strings.Contains(err.Error(), "forks Schema predecessor") {
|
||||
t.Fatalf("forked predecessor error=%v", err)
|
||||
}
|
||||
|
||||
cycleStable, cycleBase, _ := schemaCommandLineageContracts()
|
||||
mutateSchemaCommandMigrationTool(&cycleStable, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["id"] = tool.Parameters["group"]
|
||||
})
|
||||
mutateSchemaCommandMigrationTool(&cycleBase, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
tool.Parameters["other-mid"] = tool.Parameters["conversation-id"]
|
||||
})
|
||||
cycleFlags := append([]interfacesnapshot.FlagMigration(nil), flags...)
|
||||
cycleFlag := flags[0]
|
||||
cycleFlag.Legacy.Name = "id"
|
||||
cycleFlag.Canonical.Name = "other-mid"
|
||||
cycleFlags = append(cycleFlags, cycleFlag)
|
||||
cycleCommands := schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations
|
||||
cycleCommands[0].Schema.Parameters[0].To = "id"
|
||||
cycleCommands[0].Schema.Parameters = append(cycleCommands[0].Schema.Parameters,
|
||||
interfacesnapshot.CommandParameterMigration{From: "other-mid", To: "other-final"})
|
||||
missingCurrent := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{}}
|
||||
if _, err := stageSchemaCommandMigrationPredecessors(cycleStable, cycleBase, missingCurrent, cycleFlags, cycleCommands); err == nil ||
|
||||
!strings.Contains(err.Error(), "lineage cycle") {
|
||||
t.Fatalf("cross-lineage cycle error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationContract(after bool) schemaContract {
|
||||
id := parameterSchema{Type: `"string"`, Property: "resourceId", Required: true, CLIRequired: true}
|
||||
keep := parameterSchema{Type: `"string"`, Property: "keep"}
|
||||
@@ -722,6 +1271,155 @@ func schemaCommandMigrationContract(after bool) schemaContract {
|
||||
return schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{"chat": {Tools: tools}}}
|
||||
}
|
||||
|
||||
func schemaCommandLineageContracts() (schemaContract, schemaContract, schemaContract) {
|
||||
conversation := parameterSchema{
|
||||
Type: `"string"`,
|
||||
Property: "openconversationId",
|
||||
InterfaceType: "string",
|
||||
Required: true,
|
||||
CLIRequired: true,
|
||||
}
|
||||
topic := parameterSchema{
|
||||
Type: `"string"`,
|
||||
Property: "openConversationThreadId",
|
||||
InterfaceType: "string",
|
||||
Required: true,
|
||||
CLIRequired: true,
|
||||
}
|
||||
tool := toolSchema{
|
||||
PrimaryCLIPath: "chat message list-topic-replies",
|
||||
InterfaceMode: "mcp",
|
||||
InterfaceRef: `{"product_id":"im","rpc_name":"list_topic_replies"}`,
|
||||
Availability: "available",
|
||||
Parameters: map[string]parameterSchema{
|
||||
"group": conversation,
|
||||
"topic-id": topic,
|
||||
},
|
||||
Constraints: `{"require_together":[["group","topic-id"]]}`,
|
||||
Effect: "read",
|
||||
Risk: "low",
|
||||
Confirmation: "not_required",
|
||||
Idempotency: "idempotent",
|
||||
}
|
||||
stable := schemaContract{Version: schemaContractVersion, Products: map[string]productSchema{
|
||||
"chat": {Tools: map[string]toolSchema{"chat.list_topic_replies": tool}},
|
||||
}}
|
||||
|
||||
base := cloneContract(stable)
|
||||
mutateSchemaCommandMigrationTool(&base, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
delete(tool.Parameters, "group")
|
||||
tool.Parameters["conversation-id"] = conversation
|
||||
tool.Constraints = `{"require_together":[["conversation-id","topic-id"]]}`
|
||||
})
|
||||
|
||||
current := cloneContract(base)
|
||||
mutateSchemaCommandMigrationTool(¤t, "chat.list_topic_replies", func(tool *toolSchema) {
|
||||
delete(tool.Parameters, "conversation-id")
|
||||
delete(tool.Parameters, "topic-id")
|
||||
tool.Parameters["open-topic-id"] = conversation
|
||||
tool.Parameters["open-conv-thread-id"] = topic
|
||||
tool.PrimaryCLIPath = "chat topic list-replies"
|
||||
tool.Constraints = `{"require_together":[["open-conv-thread-id","open-topic-id"]]}`
|
||||
})
|
||||
return stable, base, current
|
||||
}
|
||||
|
||||
func schemaCommandLineageFlagManifest() interfacesnapshot.FlagMigrationManifest {
|
||||
beforeCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local"}
|
||||
afterCanonical := interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}
|
||||
return interfacesnapshot.FlagMigrationManifest{
|
||||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.FlagMigration{{
|
||||
Command: "dws chat message list-topic-replies",
|
||||
Legacy: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "group",
|
||||
Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"},
|
||||
After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Hidden: true, Scope: "local", AliasOf: "conversation-id"},
|
||||
},
|
||||
Canonical: interfacesnapshot.FlagMigrationSide{
|
||||
Name: "conversation-id",
|
||||
Before: beforeCanonical,
|
||||
After: afterCanonical,
|
||||
},
|
||||
State: interfacesnapshot.FlagMigrationConsumed,
|
||||
Reason: "preserve the reviewed group to conversation-id lineage",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandLineageManifest(state string) interfacesnapshot.CommandMigrationManifest {
|
||||
return interfacesnapshot.CommandMigrationManifest{
|
||||
Version: interfacesnapshot.CommandMigrationManifestVersion,
|
||||
Migrations: []interfacesnapshot.CommandMigration{{
|
||||
Kind: interfacesnapshot.CommandMigrationMove,
|
||||
Legacy: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat message list-topic-replies",
|
||||
Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true},
|
||||
},
|
||||
Replacement: interfacesnapshot.CommandMigrationSide{
|
||||
Command: "dws chat topic list-replies",
|
||||
Before: interfacesnapshot.CommandMigrationState{},
|
||||
After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true},
|
||||
},
|
||||
Schema: interfacesnapshot.CommandMigrationSchema{
|
||||
ProductID: "chat",
|
||||
SourceToolID: "chat.list_topic_replies",
|
||||
ReplacementToolID: "chat.list_topic_replies",
|
||||
Parameters: []interfacesnapshot.CommandParameterMigration{
|
||||
{From: "conversation-id", To: "open-topic-id"},
|
||||
{From: "topic-id", To: "open-conv-thread-id"},
|
||||
},
|
||||
},
|
||||
State: state,
|
||||
Reason: "move topic reply listing while retaining the legacy command",
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandLineageSnapshot(flagAfter, commandAfter bool) interfacesnapshot.Snapshot {
|
||||
legacyFlags := []interfacesnapshot.Flag{
|
||||
{Name: "conversation-id", Type: "string", Hidden: true},
|
||||
{Name: "group", Type: "string", Required: true},
|
||||
{Name: "topic-id", Type: "string", Required: true},
|
||||
}
|
||||
if flagAfter {
|
||||
legacyFlags[0].Hidden = false
|
||||
legacyFlags[0].Required = true
|
||||
legacyFlags[1].Required = false
|
||||
legacyFlags[1].Hidden = true
|
||||
legacyFlags[1].AliasOf = "conversation-id"
|
||||
}
|
||||
commands := []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{
|
||||
Path: "dws chat message list-topic-replies",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: legacyFlags,
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
},
|
||||
}
|
||||
if commandAfter {
|
||||
commands[1].Hidden = true
|
||||
commands = append(commands, interfacesnapshot.Command{
|
||||
Path: "dws chat topic list-replies",
|
||||
Runnable: true,
|
||||
Aliases: []string{},
|
||||
LocalFlags: []interfacesnapshot.Flag{},
|
||||
InheritedFlags: []interfacesnapshot.Flag{},
|
||||
})
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{"dws __complete", "dws __completeNoDesc", "dws completion", "dws help"},
|
||||
ExcludedFlags: []string{"help"},
|
||||
},
|
||||
Commands: commands,
|
||||
}
|
||||
}
|
||||
|
||||
func schemaCommandMigrationAuthorizations() []interfacesnapshot.CommandMigration {
|
||||
return []interfacesnapshot.CommandMigration{
|
||||
{
|
||||
|
||||
@@ -87,6 +87,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
var normalizePath, checkPath, mergePath, currentPath string
|
||||
var approvedFlagMigrationsPath, candidateFlagMigrationsPath string
|
||||
var approvedCommandMigrationsPath, candidateCommandMigrationsPath string
|
||||
var migrationBaseSchemaPath string
|
||||
var migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath string
|
||||
flags := flag.NewFlagSet("schema-compat", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
@@ -98,6 +99,7 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
flags.StringVar(&candidateFlagMigrationsPath, "candidate-flag-migrations", "", "detached candidate flag migration manifest")
|
||||
flags.StringVar(&approvedCommandMigrationsPath, "approved-command-migrations", "", "base-owned approved command migration manifest")
|
||||
flags.StringVar(&candidateCommandMigrationsPath, "candidate-command-migrations", "", "detached candidate command migration manifest")
|
||||
flags.StringVar(&migrationBaseSchemaPath, "migration-base-schema", "", "normalized merge-base Schema contract used to verify cross-migration lineage")
|
||||
flags.StringVar(&migrationCurrentSnapshotPath, "migration-current-snapshot", "", "current interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationBaseSnapshotPath, "migration-base-snapshot", "", "merge-base interface snapshot used for migration authorization")
|
||||
flags.StringVar(&migrationStableSnapshotPath, "migration-stable-snapshot", "", "stable interface snapshot used for migration authorization")
|
||||
@@ -149,6 +151,14 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintln(stderr, "Schema migration authorization is only valid with --check")
|
||||
return 2
|
||||
}
|
||||
if flagMigrationPair && commandMigrationPair && migrationBaseSchemaPath == "" {
|
||||
fmt.Fprintln(stderr, "combined Schema flag and command migration authorization requires --migration-base-schema")
|
||||
return 2
|
||||
}
|
||||
if migrationBaseSchemaPath != "" && (!flagMigrationPair || !commandMigrationPair) {
|
||||
fmt.Fprintln(stderr, "--migration-base-schema requires both flag and command migration manifest pairs")
|
||||
return 2
|
||||
}
|
||||
|
||||
if normalizePath != "" {
|
||||
currentPath = normalizePath
|
||||
@@ -175,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "read schema baseline: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
var flagMigrations []interfacesnapshot.FlagMigration
|
||||
if flagMigrationPair {
|
||||
migrations, err := authorizeSchemaFlagMigrations(
|
||||
flagMigrations, err = authorizeSchemaFlagMigrations(
|
||||
approvedFlagMigrationsPath,
|
||||
candidateFlagMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
@@ -187,14 +198,9 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "authorize Schema flag migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaFlagMigrations(baseline, current, migrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
if commandMigrationPair {
|
||||
migrations, err := authorizeSchemaCommandMigrations(
|
||||
commandMigrations, err := authorizeSchemaCommandMigrations(
|
||||
approvedCommandMigrationsPath,
|
||||
candidateCommandMigrationsPath,
|
||||
migrationCurrentSnapshotPath,
|
||||
@@ -205,11 +211,32 @@ func run(args []string, stdout, stderr io.Writer) int {
|
||||
fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaCommandMigrations(baseline, current, migrations)
|
||||
if flagMigrationPair {
|
||||
migrationBase, readErr := readContract(migrationBaseSchemaPath)
|
||||
if readErr != nil {
|
||||
fmt.Fprintf(stderr, "read migration merge-base Schema contract: %v\n", readErr)
|
||||
return 2
|
||||
}
|
||||
baseline, err = normalizeSchemaCommandMigrationLineage(
|
||||
baseline,
|
||||
migrationBase,
|
||||
current,
|
||||
flagMigrations,
|
||||
commandMigrations,
|
||||
)
|
||||
} else {
|
||||
baseline, err = normalizeSchemaCommandMigrations(baseline, current, commandMigrations)
|
||||
}
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema command migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
} else if flagMigrationPair {
|
||||
baseline, err = normalizeSchemaFlagMigrations(baseline, current, flagMigrations)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "normalize approved Schema flag migrations: %v\n", err)
|
||||
return 2
|
||||
}
|
||||
}
|
||||
failures := checkCompatibility(baseline, current)
|
||||
if len(failures) > 0 {
|
||||
@@ -1367,6 +1394,328 @@ func normalizeSchemaFlagMigrations(
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// normalizeSchemaCommandMigrationLineage composes two independently reviewed
|
||||
// migration receipts without inventing a second alias authority. A consumed
|
||||
// flag migration may supply the historical predecessor of a command_move
|
||||
// parameter, but only after the merge-base Schema or a retained consumed
|
||||
// command receipt proves the corresponding next hop.
|
||||
func normalizeSchemaCommandMigrationLineage(
|
||||
historical schemaContract,
|
||||
mergeBase schemaContract,
|
||||
current schemaContract,
|
||||
flagMigrations []interfacesnapshot.FlagMigration,
|
||||
commandMigrations []interfacesnapshot.CommandMigration,
|
||||
) (schemaContract, error) {
|
||||
normalized, err := normalizeSchemaFlagMigrations(historical, current, flagMigrations)
|
||||
if err != nil {
|
||||
return schemaContract{}, fmt.Errorf("normalize ordinary flag migrations: %w", err)
|
||||
}
|
||||
staged, err := stageSchemaCommandMigrationPredecessors(
|
||||
normalized,
|
||||
mergeBase,
|
||||
current,
|
||||
flagMigrations,
|
||||
commandMigrations,
|
||||
)
|
||||
if err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
return normalizeSchemaCommandMigrations(staged, current, commandMigrations)
|
||||
}
|
||||
|
||||
// stageSchemaCommandMigrationPredecessors replays only the name-changing edge
|
||||
// recorded by a base-owned consumed flag migration. It leaves interface,
|
||||
// safety, dry-run, and positional facts untouched so the ordinary checker
|
||||
// remains authoritative for every non-name change.
|
||||
func stageSchemaCommandMigrationPredecessors(
|
||||
historical schemaContract,
|
||||
mergeBase schemaContract,
|
||||
current schemaContract,
|
||||
flagMigrations []interfacesnapshot.FlagMigration,
|
||||
commandMigrations []interfacesnapshot.CommandMigration,
|
||||
) (schemaContract, error) {
|
||||
staged := cloneContract(historical)
|
||||
moveBySource := map[schemaToolRef]string{}
|
||||
for _, migration := range commandMigrations {
|
||||
if migration.Kind != interfacesnapshot.CommandMigrationMove {
|
||||
continue
|
||||
}
|
||||
ref := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
|
||||
if previous, exists := moveBySource[ref]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migrations fork Schema source tool %q between %q and %q",
|
||||
migration.Schema.SourceToolID,
|
||||
previous,
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
moveBySource[ref] = migration.Legacy.Command
|
||||
}
|
||||
|
||||
for _, migration := range commandMigrations {
|
||||
if migration.Kind != interfacesnapshot.CommandMigrationMove {
|
||||
continue
|
||||
}
|
||||
oldProduct, productExists := historical.Products[migration.Schema.ProductID]
|
||||
oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID]
|
||||
if !productExists || !toolExists {
|
||||
continue
|
||||
}
|
||||
legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ")
|
||||
replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ")
|
||||
if oldTool.PrimaryCLIPath == replacementPath {
|
||||
continue
|
||||
}
|
||||
if oldTool.PrimaryCLIPath != legacyPath {
|
||||
// Preserve the existing command normalizer's deterministic path error.
|
||||
continue
|
||||
}
|
||||
|
||||
baseProduct, baseProductExists := mergeBase.Products[migration.Schema.ProductID]
|
||||
baseTool, baseToolExists := baseProduct.Tools[migration.Schema.SourceToolID]
|
||||
currentProduct, currentProductExists := current.Products[migration.Schema.ProductID]
|
||||
currentTool, currentToolExists := currentProduct.Tools[migration.Schema.SourceToolID]
|
||||
firstHopRenames := map[string]string{}
|
||||
composedRenames := map[string]string{}
|
||||
lineageApplied := false
|
||||
|
||||
for _, parameter := range migration.Schema.Parameters {
|
||||
if _, direct := oldTool.Parameters[parameter.From]; direct {
|
||||
for _, flagMigration := range flagMigrations {
|
||||
if flagMigration.Command != migration.Legacy.Command ||
|
||||
flagMigration.Canonical.Name != parameter.From {
|
||||
continue
|
||||
}
|
||||
if _, legacyAlsoPublished := oldTool.Parameters[flagMigration.Legacy.Name]; legacyAlsoPublished {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q historical Schema tool publishes both predecessor %q and intermediate %q",
|
||||
migration.Legacy.Command,
|
||||
flagMigration.Legacy.Name,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
}
|
||||
composedRenames[parameter.From] = parameter.To
|
||||
continue
|
||||
}
|
||||
|
||||
predecessors := make([]interfacesnapshot.FlagMigration, 0, 1)
|
||||
for _, flagMigration := range flagMigrations {
|
||||
if flagMigration.Command != migration.Legacy.Command ||
|
||||
flagMigration.Canonical.Name != parameter.From {
|
||||
continue
|
||||
}
|
||||
if flagMigration.State != interfacesnapshot.FlagMigrationConsumed {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q Schema predecessor %q -> %q requires a consumed flag migration receipt",
|
||||
migration.Legacy.Command,
|
||||
flagMigration.Legacy.Name,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
if _, published := oldTool.Parameters[flagMigration.Legacy.Name]; published {
|
||||
predecessors = append(predecessors, flagMigration)
|
||||
}
|
||||
}
|
||||
if len(predecessors) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
lineageApplied = true
|
||||
if !baseProductExists || !baseToolExists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q merge-base Schema lacks source tool %q",
|
||||
migration.Legacy.Command,
|
||||
migration.Schema.SourceToolID,
|
||||
)
|
||||
}
|
||||
var stagedParameter parameterSchema
|
||||
switch migration.State {
|
||||
case interfacesnapshot.CommandMigrationPending:
|
||||
if baseTool.PrimaryCLIPath != legacyPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"pending command migration %q merge-base Schema source tool has primary_cli_path %q",
|
||||
migration.Legacy.Command,
|
||||
baseTool.PrimaryCLIPath,
|
||||
)
|
||||
}
|
||||
intermediate, exists := baseTool.Parameters[parameter.From]
|
||||
if !exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"pending command migration %q merge-base Schema lacks intermediate parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
if _, exists := baseTool.Parameters[parameter.To]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"pending command migration %q merge-base Schema already publishes final parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
stagedParameter = intermediate
|
||||
case interfacesnapshot.CommandMigrationConsumed:
|
||||
if baseTool.PrimaryCLIPath != replacementPath {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"consumed command migration %q merge-base Schema source tool has primary_cli_path %q",
|
||||
migration.Legacy.Command,
|
||||
baseTool.PrimaryCLIPath,
|
||||
)
|
||||
}
|
||||
finalParameter, exists := baseTool.Parameters[parameter.To]
|
||||
if !exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"consumed command migration %q merge-base Schema lacks final parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
if _, exists := baseTool.Parameters[parameter.From]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"consumed command migration %q merge-base Schema still publishes intermediate parameter %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
stagedParameter = oldTool.Parameters[predecessors[0].Legacy.Name]
|
||||
for _, predecessor := range predecessors {
|
||||
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
|
||||
composite := interfacesnapshot.CommandParameterMigration{From: predecessor.Legacy.Name, To: parameter.To}
|
||||
if err := validateEquivalentCommandSchemaParameter(migration, composite, oldParameter, finalParameter); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
}
|
||||
default:
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q has unsupported lineage state %q",
|
||||
migration.Legacy.Command,
|
||||
migration.State,
|
||||
)
|
||||
}
|
||||
|
||||
stagedProduct := staged.Products[migration.Schema.ProductID]
|
||||
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
|
||||
for _, predecessor := range predecessors {
|
||||
if predecessor.Legacy.Name == parameter.To {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q forms a Schema parameter lineage cycle through %q",
|
||||
migration.Legacy.Command,
|
||||
parameter.To,
|
||||
)
|
||||
}
|
||||
if existing, claimed := firstHopRenames[predecessor.Legacy.Name]; claimed && existing != parameter.From {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q forks Schema predecessor %q to both %q and %q",
|
||||
migration.Legacy.Command,
|
||||
predecessor.Legacy.Name,
|
||||
existing,
|
||||
parameter.From,
|
||||
)
|
||||
}
|
||||
oldParameter := oldTool.Parameters[predecessor.Legacy.Name]
|
||||
if migration.State == interfacesnapshot.CommandMigrationPending {
|
||||
if err := validateRenamedSchemaParameter(predecessor, oldParameter, stagedParameter); err != nil {
|
||||
return schemaContract{}, err
|
||||
}
|
||||
}
|
||||
if _, exists := baseTool.Parameters[predecessor.Legacy.Name]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q merge-base Schema still publishes predecessor parameter %q",
|
||||
migration.Legacy.Command,
|
||||
predecessor.Legacy.Name,
|
||||
)
|
||||
}
|
||||
if currentProductExists && currentToolExists {
|
||||
if _, exists := currentTool.Parameters[predecessor.Legacy.Name]; exists {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q current Schema still publishes predecessor parameter %q",
|
||||
migration.Legacy.Command,
|
||||
predecessor.Legacy.Name,
|
||||
)
|
||||
}
|
||||
}
|
||||
delete(stagedTool.Parameters, predecessor.Legacy.Name)
|
||||
firstHopRenames[predecessor.Legacy.Name] = parameter.From
|
||||
composedRenames[predecessor.Legacy.Name] = parameter.To
|
||||
}
|
||||
stagedTool.Parameters[parameter.From] = stagedParameter
|
||||
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
|
||||
staged.Products[migration.Schema.ProductID] = stagedProduct
|
||||
}
|
||||
|
||||
if !lineageApplied {
|
||||
continue
|
||||
}
|
||||
matches := schemaToolsByPrimaryPath(historical, legacyPath)
|
||||
wantRef := schemaToolRef{productID: migration.Schema.ProductID, toolID: migration.Schema.SourceToolID}
|
||||
if len(matches) != 1 || matches[0] != wantRef {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q predecessor lineage requires one exact historical Schema tool, got %#v",
|
||||
migration.Legacy.Command,
|
||||
matches,
|
||||
)
|
||||
}
|
||||
if currentProductExists && currentToolExists {
|
||||
if source, found := migratedConstraintSourceParameter(currentTool.Constraints, firstHopRenames); found {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q current Schema constraints still reference predecessor parameter %q",
|
||||
migration.Legacy.Command,
|
||||
source,
|
||||
)
|
||||
}
|
||||
}
|
||||
for _, finalTarget := range composedRenames {
|
||||
if _, cycle := firstHopRenames[finalTarget]; cycle {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q forms a Schema parameter lineage cycle through %q",
|
||||
migration.Legacy.Command,
|
||||
finalTarget,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
firstHopConstraints, firstHopOK := canonicalizeMigratedConstraints(oldTool.Constraints, firstHopRenames)
|
||||
if !firstHopOK {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q historical Schema constraints are not canonicalizable",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
baseConstraints, baseOK := canonicalizeMigratedConstraints(baseTool.Constraints, nil)
|
||||
if !baseOK {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q merge-base Schema constraints are not canonicalizable",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
stagedProduct := staged.Products[migration.Schema.ProductID]
|
||||
stagedTool := stagedProduct.Tools[migration.Schema.SourceToolID]
|
||||
switch migration.State {
|
||||
case interfacesnapshot.CommandMigrationPending:
|
||||
if firstHopConstraints != baseConstraints {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"pending command migration %q predecessor lineage changed merge-base Schema constraints",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
stagedTool.Constraints = baseTool.Constraints
|
||||
case interfacesnapshot.CommandMigrationConsumed:
|
||||
composedConstraints, composedOK := canonicalizeMigratedConstraints(oldTool.Constraints, composedRenames)
|
||||
if !composedOK || composedConstraints != baseConstraints {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"consumed command migration %q predecessor lineage changed merge-base Schema constraints",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
stagedTool.Constraints = firstHopConstraints
|
||||
}
|
||||
stagedProduct.Tools[migration.Schema.SourceToolID] = stagedTool
|
||||
staged.Products[migration.Schema.ProductID] = stagedProduct
|
||||
}
|
||||
return staged, nil
|
||||
}
|
||||
|
||||
// normalizeSchemaCommandMigrations projects only the Schema consequences that
|
||||
// are coupled to an already-authorized CLI command migration. It rewrites a
|
||||
// cloned historical contract; the ordinary checker still rejects every field
|
||||
@@ -1478,12 +1827,30 @@ func normalizeSchemaCommandMigrations(
|
||||
)
|
||||
}
|
||||
}
|
||||
if oldTool.Constraints != newSource.Constraints {
|
||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
||||
if oldOK && newOK && oldConstraints == newConstraints {
|
||||
normalizedTool.Constraints = newSource.Constraints
|
||||
}
|
||||
oldConstraints, oldOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames)
|
||||
if !oldOK {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q historical Schema constraints are not canonicalizable",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
newConstraints, newOK := canonicalizeMigratedConstraints(newSource.Constraints, nil)
|
||||
if !newOK {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q current Schema constraints are not canonicalizable",
|
||||
migration.Legacy.Command,
|
||||
)
|
||||
}
|
||||
if source, found := migratedConstraintSourceParameter(newSource.Constraints, renames); found {
|
||||
return schemaContract{}, fmt.Errorf(
|
||||
"approved command migration %q current constraints still reference legacy Schema constraint parameter %q",
|
||||
migration.Legacy.Command,
|
||||
source,
|
||||
)
|
||||
}
|
||||
normalizedTool.Constraints = oldConstraints
|
||||
if oldConstraints == newConstraints {
|
||||
normalizedTool.Constraints = newSource.Constraints
|
||||
}
|
||||
normalizedTool.PrimaryCLIPath = replacementPath
|
||||
|
||||
@@ -1938,6 +2305,23 @@ func canonicalizeMigratedConstraints(raw string, renames map[string]string) (str
|
||||
return string(encoded), err == nil
|
||||
}
|
||||
|
||||
func migratedConstraintSourceParameter(raw string, renames map[string]string) (string, bool) {
|
||||
groups, ok := parseMigrationConstraintsStrict(raw)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
for _, kind := range []string{"mutually_exclusive", "require_one_of", "require_together"} {
|
||||
for _, group := range groups[kind] {
|
||||
for _, member := range group {
|
||||
if _, renamed := renames[member]; renamed {
|
||||
return member, true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func parseMigrationConstraintsStrict(raw string) (map[string][][]string, bool) {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
|
||||
@@ -1739,11 +1739,12 @@ func TestCrossPlatformCoverageSchemaConsumedReceiptIsNoOpForAfterBaseline(t *tes
|
||||
t.Fatalf("consumed receipt did not preserve compatibility: %v", failures)
|
||||
}
|
||||
|
||||
// Once stable also reaches after, retaining the consumed receipt is stale;
|
||||
// deleting it yields no authorization and the already-after Schema passes.
|
||||
// Once stable also reaches after, retaining or deleting the consumed receipt
|
||||
// yields no authorization and the already-after Schema passes.
|
||||
writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaFlagMigrationSnapshot(true))
|
||||
if _, err := authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath); err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
|
||||
t.Fatalf("stale consumed receipt error = %v", err)
|
||||
migrations, err = authorizeSchemaFlagMigrations(approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath)
|
||||
if err != nil || len(migrations) != 0 {
|
||||
t.Fatalf("retained consumed receipt authorizations = %#v, %v", migrations, err)
|
||||
}
|
||||
emptyManifest := interfacesnapshot.FlagMigrationManifest{
|
||||
Version: interfacesnapshot.FlagMigrationManifestVersion,
|
||||
|
||||
@@ -173,8 +173,9 @@ func main() {
|
||||
currentSnapshot := flag.String("migration-current-snapshot", "", "candidate interface snapshot")
|
||||
baseSnapshot := flag.String("migration-base-snapshot", "", "base interface snapshot")
|
||||
stableSnapshot := flag.String("migration-stable-snapshot", "", "stable interface snapshot")
|
||||
_ = flag.String("approved-command-migrations", "", "base command ledger")
|
||||
_ = flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||
migrationBaseSchema := flag.String("migration-base-schema", "", "merge-base Schema contract")
|
||||
approvedCommand := flag.String("approved-command-migrations", "", "base command ledger")
|
||||
candidateCommand := flag.String("candidate-command-migrations", "", "candidate command ledger")
|
||||
flag.Parse()
|
||||
|
||||
if *normalize != "" {
|
||||
@@ -214,6 +215,29 @@ func main() {
|
||||
os.Exit(2)
|
||||
}
|
||||
}
|
||||
commandPair := *approvedCommand != "" || *candidateCommand != ""
|
||||
if commandPair && (*approvedCommand == "" || *candidateCommand == "") {
|
||||
fmt.Fprintln(os.Stderr, "stable Schema guard received a partial command migration pair")
|
||||
os.Exit(2)
|
||||
}
|
||||
if commandPair {
|
||||
data, err := os.ReadFile(*migrationBaseSchema)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if !strings.Contains(string(data), "BASE_AUTHORITY") {
|
||||
fmt.Fprintf(os.Stderr, "migration base Schema is not base-owned: %s\n", data)
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY")
|
||||
} else {
|
||||
if *migrationBaseSchema != "" {
|
||||
fmt.Fprintln(os.Stderr, "flag-only Schema check received command migration lineage")
|
||||
os.Exit(2)
|
||||
}
|
||||
fmt.Fprintln(os.Stdout, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED")
|
||||
}
|
||||
currentData, err := os.ReadFile(*current)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
@@ -418,9 +442,16 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi
|
||||
authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "governed checker protects stable Schema contract",
|
||||
name: "governed checker protects stable Schema contract",
|
||||
baseGovernance: "complete",
|
||||
commandGovernance: "complete",
|
||||
checkerMode: "stable-schema-guard",
|
||||
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
||||
},
|
||||
{
|
||||
name: "lineage capable checker omits command lineage for flag-only governance",
|
||||
baseGovernance: "complete",
|
||||
checkerMode: "stable-schema-guard",
|
||||
checkerMode: "flag-only-schema-guard",
|
||||
authorityMarker: "STABLE_SCHEMA_CONTRACT_ENFORCED",
|
||||
},
|
||||
{
|
||||
@@ -611,7 +642,7 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
switch test.baseGovernance {
|
||||
case "complete":
|
||||
checkerSource := governedSchemaCheckerSource
|
||||
if test.checkerMode == "stable-schema-guard" {
|
||||
if test.checkerMode == "stable-schema-guard" || test.checkerMode == "flag-only-schema-guard" {
|
||||
checkerSource = stableSchemaGuardCheckerSource
|
||||
}
|
||||
schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "schema-compat", "main.go"), checkerSource, 0o644)
|
||||
@@ -794,6 +825,12 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) {
|
||||
if test.baseGovernance == "complete" && strings.Count(got, "BASE_INTERFACE_HELPER_GENERATE=BASE") != 3 {
|
||||
t.Fatalf("governed Schema check did not generate three base-owned interface snapshots; output:\n%s", got)
|
||||
}
|
||||
if test.checkerMode == "stable-schema-guard" && strings.Count(got, "BASE_SCHEMA_LINEAGE=BASE_AUTHORITY") != 2 {
|
||||
t.Fatalf("governed Schema check did not pass the base-owned Schema lineage to both historical checks; output:\n%s", got)
|
||||
}
|
||||
if test.checkerMode == "flag-only-schema-guard" && strings.Count(got, "FLAG_ONLY_SCHEMA_LINEAGE_OMITTED") != 2 {
|
||||
t.Fatalf("flag-only Schema check received command migration lineage; output:\n%s", got)
|
||||
}
|
||||
|
||||
wrongStable := exec.Command(
|
||||
"sh",
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# Release compatibility is one decision seam over the sealed source tree.
|
||||
# Trusted release tooling may orchestrate this script, but the explicitly
|
||||
# selected repository remains the authority for both CLI and Schema checks.
|
||||
|
||||
REPO_ROOT=""
|
||||
BASE_REF=""
|
||||
STABLE_REF=""
|
||||
CANDIDATE_REF="HEAD"
|
||||
|
||||
usage() {
|
||||
printf '%s\n' \
|
||||
"usage: $0 --repo-root <path> --base-ref <ref> --stable-ref <ref> [--candidate-ref <ref>]" >&2
|
||||
}
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--repo-root)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
REPO_ROOT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--base-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
BASE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--stable-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
STABLE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
--candidate-ref)
|
||||
[ "$#" -ge 2 ] || { usage; exit 2; }
|
||||
CANDIDATE_REF="$2"
|
||||
shift 2
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
printf 'error: unknown argument: %s\n' "$1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$REPO_ROOT" ] && [ -n "$BASE_REF" ] && [ -n "$STABLE_REF" ] || {
|
||||
usage
|
||||
exit 2
|
||||
}
|
||||
|
||||
REPO_ROOT="$(CDPATH= cd -- "$REPO_ROOT" && pwd -P)" || {
|
||||
printf 'error: release source root is not available: %s\n' "$REPO_ROOT" >&2
|
||||
exit 2
|
||||
}
|
||||
GIT_ROOT="$(git -C "$REPO_ROOT" rev-parse --show-toplevel 2>/dev/null)" || {
|
||||
printf 'error: release source root is not a Git worktree: %s\n' "$REPO_ROOT" >&2
|
||||
exit 2
|
||||
}
|
||||
[ "$GIT_ROOT" = "$REPO_ROOT" ] || {
|
||||
printf 'error: release source root must be the Git worktree root: %s\n' "$REPO_ROOT" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
CLI_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-interface-baselines.sh"
|
||||
SCHEMA_CHECK="$REPO_ROOT/scripts/policy/check-authoritative-schema-compatibility.sh"
|
||||
for check in "$CLI_CHECK" "$SCHEMA_CHECK"; do
|
||||
[ -x "$check" ] || {
|
||||
printf 'error: authoritative release compatibility checker is unavailable: %s\n' "$check" >&2
|
||||
exit 2
|
||||
}
|
||||
done
|
||||
|
||||
resolve_commit() {
|
||||
label="$1"
|
||||
ref="$2"
|
||||
commit="$(git -C "$REPO_ROOT" rev-parse --verify "${ref}^{commit}" 2>/dev/null)" || {
|
||||
printf 'error: release %s ref is not available in sealed source: %s\n' "$label" "$ref" >&2
|
||||
return 2
|
||||
}
|
||||
[ -n "$commit" ] || {
|
||||
printf 'error: release %s ref resolved to an empty commit: %s\n' "$label" "$ref" >&2
|
||||
return 2
|
||||
}
|
||||
printf '%s\n' "$commit"
|
||||
}
|
||||
|
||||
# Freeze the complete comparison tuple before either checker runs. Otherwise a
|
||||
# concurrent ref update (or the first checker itself) could make Schema inspect
|
||||
# different Git objects from the CLI gate even when both receive the same names.
|
||||
BASE_COMMIT="$(resolve_commit base "$BASE_REF")"
|
||||
STABLE_COMMIT="$(resolve_commit stable "$STABLE_REF")"
|
||||
CANDIDATE_COMMIT="$(resolve_commit candidate "$CANDIDATE_REF")"
|
||||
|
||||
printf '==> Checking authoritative CLI compatibility\n'
|
||||
if "$CLI_CHECK" \
|
||||
--base-ref "$BASE_COMMIT" \
|
||||
--stable-ref "$STABLE_COMMIT" \
|
||||
--candidate-ref "$CANDIDATE_COMMIT"; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
printf 'error: authoritative CLI compatibility failed\n' >&2
|
||||
exit "$status"
|
||||
fi
|
||||
|
||||
printf '==> Checking authoritative Schema compatibility\n'
|
||||
if "$SCHEMA_CHECK" \
|
||||
--base-ref "$BASE_COMMIT" \
|
||||
--stable-ref "$STABLE_COMMIT" \
|
||||
--candidate-ref "$CANDIDATE_COMMIT"; then
|
||||
:
|
||||
else
|
||||
status=$?
|
||||
printf 'error: authoritative Schema compatibility failed\n' >&2
|
||||
exit "$status"
|
||||
fi
|
||||
@@ -19,7 +19,7 @@ usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: release.sh <prerelease|stable> <version> [options]
|
||||
|
||||
Runs the full test, command-compatibility, package, and install preflight.
|
||||
Runs the full test, authoritative CLI/Schema compatibility, package, and install preflight.
|
||||
The default is validation only. Official publication is cloud-only.
|
||||
|
||||
Options:
|
||||
@@ -359,10 +359,12 @@ else
|
||||
make policy
|
||||
|
||||
if [ -n "$previous_stable" ]; then
|
||||
printf '==> Comparing command tree with %s\n' "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
printf '==> Comparing authoritative CLI and Schema with %s\n' "$previous_stable"
|
||||
"$SCRIPT_DIR/check-release-compatibility.sh" \
|
||||
--repo-root "$ROOT" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$previous_stable"
|
||||
--stable-ref "$previous_stable" \
|
||||
--candidate-ref HEAD
|
||||
fi
|
||||
|
||||
printf '==> Building local release artifacts for %s\n' "$VERSION"
|
||||
@@ -412,11 +414,13 @@ printf '==> Revalidating delivered stable baseline %s\n' "$previous_stable"
|
||||
require_delivered_previous_stable
|
||||
|
||||
if [ "$previous_stable" != "$previous_stable_before_refresh" ]; then
|
||||
printf '==> Stable authority advanced from %s to %s; rechecking command compatibility\n' \
|
||||
printf '==> Stable authority advanced from %s to %s; rechecking authoritative CLI and Schema compatibility\n' \
|
||||
"${previous_stable_before_refresh:-none}" "$previous_stable"
|
||||
"$ROOT/scripts/policy/check-command-compatibility.sh" \
|
||||
"$SCRIPT_DIR/check-release-compatibility.sh" \
|
||||
--repo-root "$ROOT" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$previous_stable"
|
||||
--stable-ref "$previous_stable" \
|
||||
--candidate-ref HEAD
|
||||
fi
|
||||
|
||||
if [ "$PUBLISH" -eq 1 ]; then
|
||||
|
||||
@@ -1212,6 +1212,51 @@ Flags:
|
||||
--msg-ids string 消息 ID 列表,逗号分隔 (必填)
|
||||
```
|
||||
|
||||
### emotion (个人收藏表情)
|
||||
|
||||
#### 列出个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion list [flags]
|
||||
Example:
|
||||
dws chat emotion list --format json
|
||||
```
|
||||
当前用户身份由 MCP server 注入,无业务参数。
|
||||
|
||||
#### 发送个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion send [flags]
|
||||
Example:
|
||||
dws chat emotion send --media-id <mediaId> --group <openConversationId>
|
||||
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
|
||||
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>
|
||||
Flags:
|
||||
--media-id string 表情媒体 ID (必填)
|
||||
--emotion-id string 表情 ID (可选)
|
||||
--conversation-id string 群聊 openConversationId(与 --group 等价)
|
||||
--group string 群聊 openConversationId
|
||||
--user string 单聊接收人 userId,CLI 会解析为 openDingTalkId
|
||||
--open-dingtalk-id string 单聊接收人 openDingTalkId
|
||||
--uuid string 幂等键
|
||||
--idempotency-key string 幂等键(--uuid 等价)
|
||||
```
|
||||
目标参数 `--conversation-id/--group`、`--user`、`--open-dingtalk-id` 三选一。该命令会真实发送表情,执行前必须确认目标和 mediaId。
|
||||
|
||||
#### 新增个人收藏表情
|
||||
```
|
||||
Usage:
|
||||
dws chat emotion favorite [flags]
|
||||
Example:
|
||||
dws chat emotion favorite --media-id <mediaId> --name "赞"
|
||||
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>
|
||||
Flags:
|
||||
--media-id string 待收藏 mediaId (必填)
|
||||
--name string 表情名称
|
||||
--source-conversation-id string 来源会话 ID,需与 --source-message-id 成对指定
|
||||
--source-message-id string 来源消息 ID,需与 --source-conversation-id 成对指定
|
||||
```
|
||||
|
||||
### list-top-conversations (置顶会话)
|
||||
|
||||
#### 拉取置顶会话列表
|
||||
@@ -2129,6 +2174,9 @@ Flags:
|
||||
用户说"群公告/发布公告/修改公告/查看公告/定时公告" → `chat group notice create/edit/get/list`
|
||||
用户说"批量查消息/按ID查消息/根据消息ID查" → `chat message list-by-ids`
|
||||
用户说"批量查消息回复/表情回复/文字回复/消息回应列表" → `chat message list-emotion-replies`
|
||||
用户说"查看个人收藏表情/列出我的收藏表情" → `chat emotion list`
|
||||
用户说"发送个人收藏表情/发表情包" → `chat emotion send`
|
||||
用户说"收藏表情/新增个人收藏表情" → `chat emotion favorite`
|
||||
用户说"emoji回应/表情回应/给消息加表情" → `chat message add-emoji`
|
||||
用户说"取消emoji回应/移除表情回应" → `chat message remove-emoji`
|
||||
用户说"文字表情回应/添加文字表情" → `chat message add-text-emotion`
|
||||
@@ -2200,6 +2248,7 @@ Flags:
|
||||
- `chat message list-emotion-replies` — 批量拉取消息的表情回复和文字回复
|
||||
- `chat message add-text-emotion` / `update-text-emotion` / `remove-text-emotion` — 对消息添加、原地更新或移除文字表情回应
|
||||
- `chat message create-text-emotion` — 创建文字表情模板,返回 emotionId 供 add-text-emotion 使用
|
||||
- `chat emotion list` / `send` / `favorite` — 当前用户个人收藏表情列表、发送和新增;不同于消息 reaction/文字回应
|
||||
- `chat category list` — 获取用户自定义会话分组列表
|
||||
- `chat category list-conversations` — 拉取指定分组下的会话列表
|
||||
- `chat category create-smart` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则)
|
||||
|
||||
@@ -47,6 +47,7 @@ metadata:
|
||||
| 查看指定群成员(用户/机器人) | `dws chat +chat-members-list --group <群名或ID>` | 唯一解析并全量读取 |
|
||||
| 获取群邀请链接 | `dws chat +chat-invite-url --group <群名或ID>` | 多候选时停止 |
|
||||
| 查看群机器人 | `dws chat +chat-bots --group <群名或ID>` | 返回稳定 `bots[]` |
|
||||
| 个人收藏表情列表/发送/收藏 | `dws chat emotion list/send/favorite` | 约束见 leaf Schema |
|
||||
| 修改群名称 | `dws chat group rename --id <openConversationId> --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` |
|
||||
| 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 |
|
||||
| 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` |
|
||||
|
||||
@@ -57,8 +57,10 @@
|
||||
| `message add-emoji` / `remove-emoji` | 默认 emoji reaction |
|
||||
| `message create-text-emotion` / `add-text-emotion` / `update-text-emotion` / `remove-text-emotion` | 文字表情 |
|
||||
| `message list-emotion-replies` | 批量 reaction/文字回应 |
|
||||
| `emotion list` / `send` / `favorite` | 当前用户个人收藏表情列表、发送和新增 |
|
||||
|
||||
Favorite、消息 Pin、消息 Top 与会话 Top 是四种对象,不能互换。
|
||||
个人收藏表情与消息 reaction/文字回应不同;发送收藏表情使用 `chat emotion send`,给已有消息贴表情使用 `chat message add-emoji` 或 `chat message add-text-emotion`。
|
||||
|
||||
## 群与成员底层能力
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: dingtalk-doc
|
||||
description: 钉钉在线文字文档(adoc)本体及其内容的操作:查找、创建、读取、文档信息、编辑、块、评论、附件与媒体、白板卡片、导入、导出(docx/markdown/pdf)、版本、模板、权限、分享及Markdown/JSONML写入。不包括:文档空间与钉盘的文件管理(归 dingtalk-drive,doc 同名原子命令已弃用)、知识库空间与节点管理(归 dingtalk-wiki)、原生 .md 文件读写(归 dingtalk-misc)、电子表格 axls(归 dingtalk-misc)、AI 表格 able(归 dingtalk-aitable)。命令前缀:dws doc。
|
||||
description: 钉钉在线文字文档(adoc)的查找创建、读写、文档信息、白板卡片、附件、评论、媒体、导入导出、版本、模板、权限分享;本地文件转在线文档或协作编辑归 dingtalk-doc。原文件/文件夹归 dingtalk-drive,知识库归 dingtalk-wiki,原生 .md/axls 归 dingtalk-misc,able 归 dingtalk-aitable。前缀:dws doc。
|
||||
metadata:
|
||||
cli_version: ">=0.2.14"
|
||||
category: product
|
||||
@@ -35,21 +35,23 @@ metadata:
|
||||
|
||||
## Golden Route
|
||||
|
||||
选择最小入口。ID/URL 直用;只有标题时先搜索,唯一候选后执行,禁止默认第一项。
|
||||
走最小入口:ID/URL 直用;标题先搜索,唯一命中再执行。成本顺序:稳定 ID → shortcut → 局部读 → 精确写;禁以产品 Schema、全文或原子命令起步。
|
||||
|
||||
| 用户意图 | 唯一推荐入口 | 关键边界 |
|
||||
|---|---|---|
|
||||
| 按标题或主题定位文档 | `dws doc +search --query <关键词>` | 检查候选类型与分页;需要正文时再用真实 `nodeId` 执行 `+fetch` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 |
|
||||
| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` |
|
||||
| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node <ID或URL>` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` |
|
||||
| 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node <ID或URL>` | 仅打开任务所需的 `--include-*`,不要默认全取 |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation |
|
||||
| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 |
|
||||
| 追加、覆盖或精确编辑 block | `dws doc +update --node <ID或URL> --command <动作>` | 唯一文本直接 `str_replace`;章节/block 先局部取 ID 再精准修改;整篇覆盖才用 overwrite |
|
||||
| 重要内容更新且需要恢复点 | `dws doc +checkpoint-update` | 自动保存版本、更新并回读;检查 `steps` 和 `compensation` |
|
||||
| 版本操作 | `dws doc +version-save --node` / `dws doc +version-list --node` / `dws doc +version-revert --node --version` | 快照/列表/回滚 |
|
||||
| 导出为 docx/markdown/pdf | `dws doc +export --export-format <格式>` | 格式必须显式指定;普通文件下载切 `dingtalk-drive` |
|
||||
| 导入本地文件为在线对象 | `dws doc +import --file <相对路径>` | folder/workspace 可选,缺省导入默认根;纯上传切 `dingtalk-drive` |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | 没有名称/关键词时使用;不调用 search/create |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 有查询词才使用;零或多候选停止 |
|
||||
| 本地文件转在线文档 | `dws doc +import --file <相对路径>` | “传上去/放进文件夹”不改变路由;在线改、协作编辑或转换用 import,仅保留原文件切 `dingtalk-drive` |
|
||||
| 封面/背景 | `+resource-update/+resource-delete`;`+background-update/+background-delete` | 写后 `+inspect --include-style`;禁查 Catalog |
|
||||
| 浏览模板 | `dws doc +template-list [--source MY\|PUBLIC]` | “我的/我这边”只查 MY;明确公开才查 PUBLIC;“有哪些/全部”翻页至完整 |
|
||||
| 搜索模板 | `dws doc +template-search --query <名称或关键词>` | 来源可选 MY/PUBLIC;零命中停止,禁止拿无关模板替代;多候选消歧 |
|
||||
| 从模板创建 | `dws doc +create-from-template --template-id <唯一ID>` | 已有唯一 templateId 才创建;不重复 list/search |
|
||||
| 创建评论或聚合待处理评论 | `dws doc +comment-create [--selection]` / `+review` | 划词统一用 `+comment-create`;后续操作使用真实 `commentKey` |
|
||||
| 添加/调整/移除协作者权限 | `dws doc +access-grant/+access-change/+access-revoke` | 先读取现有权限;姓名歧义或 profile 不一致时禁止写入 |
|
||||
@@ -58,18 +60,20 @@ metadata:
|
||||
|
||||
## 关键结果语义
|
||||
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;标题不能替代稳定 ID。
|
||||
- 按状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读且不重试写入;`retryable` 仅限明确未开始;权限、参数、认证失败停止。
|
||||
- 保留真实 `nodeId`、URL、资源类型和容器;同轮创建后直接复用返回 ID,禁止再按标题或钉盘搜索。
|
||||
- 先消费完整回执;Runtime 已分片/回读时不重复读取。只有局部验收、`partial_success` 或 commit-unknown 才再 `+fetch`。
|
||||
- 状态恢复:`partial_success` 只补未完成步骤;`unknown` 先回读、不重试写;`retryable` 仅限明确未开始;权限/参数/认证失败停止。
|
||||
- 仅在结果明确且关键内容回读匹配后报告写入完成。
|
||||
- 搜索、列表和批量结果必须检查 `complete`、`hasMore`、continuation 和失败项;单页结果不得表述为完整集合。
|
||||
- 搜索/列表检查 `complete`、`hasMore`、cursor 和失败项。“有哪些/列出/全部”逐页至完整;只有示例/前 N 条可提前停止并声明范围。
|
||||
- `+import` 已含上传、转换和轮询;成功检查 `success=true`、`taskId`、`documentUrl`。超时或中断保留 `taskId`、查询原任务,禁止重导。
|
||||
- 导出/下载仅用工作目录相对路径,默认不覆盖并原子落盘。
|
||||
|
||||
## 参数与安全边界
|
||||
|
||||
- `@file` 统一协议:已有或临时文件先暂存到 cwd 后传 `@相对路径`;单次生成文本优先 `--content -` (stdin);禁止绝对路径和 `..`。
|
||||
- `@file`:已有或临时文件先暂存到 cwd;传 `@相对路径`,禁绝对路径和 `..`。
|
||||
- `doc +update` 的动作由 `--command` 指定;block 操作的 ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表。
|
||||
- Schema 门禁:已知只读路由且参数明确时直接执行;写命令或 selection/参数不清、准备 Help 时,本轮仅查一次 `dws schema --cli-path "doc +<leaf>" --compact --fields use_when,avoid_when,parameters,constraints,confirmation -f json`;禁用产品级/`--all`。
|
||||
- 执行前消费其中的 `confirmation`:`user_required` 且用户已确认同一目标/动作/参数才加 `--yes`,否则预览/询问;禁止靠失败探测门禁。
|
||||
- Schema 门禁:不确定时仅查一次精确 leaf:`--fields use_when,avoid_when,parameters,constraints,confirmation`;禁用产品级/`--all`。准备 Help 时,本轮仅查一次。
|
||||
- 消费本页或精确 Schema 的 `confirmation`:`user_required` 且原请求/预授权已确认目标、动作、参数时,首调即加 `--yes`;否则预览/询问;禁止靠失败探测门禁。
|
||||
- JSONML 顶层必须是单个非空元素;禁止 `[[...]]` 元素数组包裹。
|
||||
|
||||
## 按需加载
|
||||
@@ -80,25 +84,24 @@ Golden Route 已给出命令且参数足够时,禁止读取 reference。其余
|
||||
|---|---|
|
||||
| 低频/无 shortcut 意图消歧 | [intent-guide.md](references/intent-guide.md) / [doc.md](references/doc.md) 对应章节 |
|
||||
| 分页、`partial_success`、`status=unknown` 或恢复 | [contracts.md](references/contracts.md) |
|
||||
| 复杂 JSONML/非默认操作 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 复杂 JSONML、长文或局部精准读写 | [create](references/doc/doc-create.md) / [read](references/doc/doc-read.md) / [update](references/doc/doc-update.md) |
|
||||
| block/划词评论/媒体/封面/背景高级参数 | [block](references/doc/doc-block.md) / [comment](references/doc/doc-comment.md) / [media](references/doc/doc-media.md) |
|
||||
| 导出/导入失败恢复 | [export](references/doc/doc-export.md) / [import](references/doc/doc-import.md) |
|
||||
|
||||
`+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;禁止预加载或连读。
|
||||
常规 `+create`、`+fetch`、`+update` append/overwrite、`+export`、`+import` 禁止读取 reference;需策略时仅读上表一项,禁预加载/连读。
|
||||
|
||||
## 错误最短路径
|
||||
|
||||
1. 零命中、多候选、类型不明或分页不完整:停止后续写入,展示候选或 continuation;禁止默认第一项。
|
||||
2. Help 不参与选路;按上方门禁先消费一次精确 leaf Schema。只有真实 `unknown flag`/契约漂移后才查一次 leaf Help;`unknown command` 只查一次 shortcut 清单,禁止试探后缀和 `dws doc --help | grep/head`。
|
||||
3. `REVISION_CONFLICT`:重新读取当前 revision,展示差异;未经用户确认不得改成无 revision 覆盖。
|
||||
4. `partial_success`:保留已完成步骤,优先执行补偿或从 checkpoint 继续,不重放成功步骤。
|
||||
5. `doc_write_commit_unknown`:先回读文档确认状态;禁止自动重试创建或追加。
|
||||
6. 认证、权限或 profile 错误:只读取 `dingtalk-shared` 对应 reference;不要尝试同义底层命令绕过。
|
||||
7. 导出或媒体失败:保留 `jobId/resourceId/nodeId` 后停止;禁止 `curl`、安装依赖、Python 文档库或手写 HTTP 兜底。
|
||||
4. `doc_write_commit_unknown`:先回读;禁止自动重试创建或追加。
|
||||
5. 认证、权限或 profile 错误:只读 `dingtalk-shared` 对应 reference,不用底层命令绕过。
|
||||
6. 导出/媒体失败:保留稳定 ID 后停止;禁止网络请求、安装依赖或本地文档库兜底。
|
||||
|
||||
## 跨产品边界
|
||||
|
||||
- 普通文件、目录、纯上传下载、节点存储权限 → `dingtalk-drive`
|
||||
- 普通文件/目录/纯上传下载/节点权限 → `dingtalk-drive`;保留原文件用原子命令 `dws drive upload --workspace`,在线转换用 `doc +import --workspace`
|
||||
- 知识库空间、节点层级和成员管理 → `dingtalk-wiki`
|
||||
- 原生 `.md` 文件读取和编辑 → `dingtalk-misc`
|
||||
- `axls` / `able` → 对应电子表格或多维表 Skill
|
||||
|
||||
@@ -13,10 +13,12 @@
|
||||
| `success` | 所有计划步骤完成,且需要验证的内容已经回读 | 可以向用户报告完成 |
|
||||
| `partial_success` | 已发生部分副作用,后续步骤失败 | 检查 `steps` 与 `compensation`,不得重放成功步骤 |
|
||||
| `unknown` | 请求已发出但无法确定服务端是否提交 | 先回读目标;创建和追加禁止自动重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable` 和 `suggestedActions` 决定是否重试 |
|
||||
| `retryable` | 服务端明确业务执行尚未开始,且允许重试 | 遵循 `retry_after_seconds`,最多有界重试一次 |
|
||||
| `failed` | 已确认没有完成目标动作 | 根据 `retryable`、`actions` 和 details 决定是否重试 |
|
||||
|
||||
`ok=true` 或进程退出码为零不能替代 `status/verified`。写回执应保留 operation、target、steps、warnings、failures、compensation 和 verification 摘要。
|
||||
进程退出码为零不能替代业务证据。采用 `doc.operation.v1` 的 shortcut 回执固定提供 `contractVersion/ok/status/complete/operation/steps/data/warnings/compensation`;`target/failures/verification` 只有实际操作返回时才能消费,不是通用字段。`+import` 使用现有导入回执,成功时检查 `success=true`、`taskId`、`documentUrl`、`documentName` 与 `documentType`,不要要求不存在的 `status/steps`。业务 `status` 不应与框架外层 `outcome` 混为一谈。
|
||||
|
||||
稳定返回 ID/任务 ID 只用于定位目标、恢复流程或继续查询,不能单独证明写操作成功。成功证据优先级从高到低为:与该操作匹配的明确服务端成功终态 → 契约要求的写后读回匹配 → 仅 transport/退出码;异步任务必须使用返回的任务 ID 查询到成功终态。只有成功终态成立且所有必要回读均匹配时才报告成功;`partial_success`、`unknown`、未完成任务或仅返回资源/任务 ID 都不得报告完成。Runtime 已给出充分回读证据时,不为“再确认一次”重复请求。
|
||||
|
||||
## 分页与完整性
|
||||
|
||||
@@ -24,14 +26,14 @@
|
||||
|
||||
- `complete=true`:已证明覆盖请求范围;
|
||||
- `hasMore=true`:仍有后续页,必须保留有效 continuation;
|
||||
- `truncated=true`:因 maxPages/maxItems/timeout 等边界停止;
|
||||
- `failures[]`:已返回部分数据但某一页或后处理失败。
|
||||
- `truncated=true`:成功返回因 `max_pages` 或 `max_items` 边界停止,并通过 `stopReason` 说明原因;
|
||||
- 后续页请求失败、cursor 缺失/停滞/循环时返回 typed error;部分结果位于 `details.items`,并保留 `details.status=partial_success`、`complete=false`、`reason/page/nextCursor/count`。不要期待成功回执的空 `failures[]` 承载这类错误,也不要把 timeout 写成 `truncated`。
|
||||
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。
|
||||
只有 `complete=true` 且没有未处理失败时,才能把结果描述为完整集合。用户问“有哪些”“全部”或要求“列出结果”时,使用返回的 cursor/continuation 继续取页直至完整;只有用户明确要示例、前 N 条或接受部分结果时才可提前停止,并说明已覆盖范围。分页应复用原查询与过滤条件,不得换命令或放宽关键词。
|
||||
|
||||
## 错误
|
||||
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 和 suggested actions。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。
|
||||
结构化错误至少区分 validation、not_found、ambiguous、type_mismatch、revision_conflict、confirmation_required、permission_denied、partial_success 和 commit_unknown,并提供 failure stage、retryable 与已有的 `actions`/details。权限、认证和参数错误直接进入 `failed`;写请求只有明确 `execution_started=false` 才能进入 `retryable`,其余传输异常进入 `unknown`。`retryable=false` 表示自动重放不安全,不代表用户检查状态后永远不能重新发起。不要发明 `suggestedAction` 或顶层 `nextCommand` 字段。
|
||||
|
||||
## 安全落盘
|
||||
|
||||
|
||||
@@ -26,10 +26,14 @@
|
||||
dws doc +template-search --query "周报" --source PUBLIC --format json
|
||||
```
|
||||
|
||||
来源按用户原话守门:“我的模板/我这边”只查 `MY`,明确“公开/钉钉模板库”才查 `PUBLIC`;不得为了凑结果跨来源扩展。未指定来源时保持默认 `MY`。
|
||||
|
||||
- `selection.status=resolved`:取唯一候选的 `templateId`。
|
||||
- `selection.status=not_found`:报告零命中后停止。
|
||||
- `selection.status=not_found`:报告零命中后停止;不得改用语义不相干的热门模板,更不得擅自创建文档。
|
||||
- `selection.status=selection_required`:展示候选并要求用户选择,禁止默认第一项。
|
||||
|
||||
若返回 `hasMore=true`,沿原 query/source 使用 cursor 继续搜索;只有服务端返回完整结果后才能判断零命中或完整候选集。
|
||||
|
||||
选定后只创建一次:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -12,7 +12,7 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
|
||||
- 统一输入协议:已有或临时文件先暂存到当前工作目录后传 `@相对文件`;单次生成文本可用 `--content -` 从 stdin 读取。
|
||||
- `@file` 禁止绝对路径和 `..` 逃逸;不要直接引用宿主临时目录。
|
||||
- `--name` 是文档名称,不等于正文中的 heading。用户明确要求“正文一级标题”时,正文仍须包含对应 H1。
|
||||
- `--name` 是文档名称;默认不要再在正文开头重复同名 H1,只有用户明确要求正文一级标题时才保留。
|
||||
- JSONML 顶层必须是数组;仅在确实需要富结构时加载 JSONML cookbook。
|
||||
|
||||
`+create` 负责创建、长 Markdown 分片和最终回读验证。正常成功结果至少包含:
|
||||
@@ -35,6 +35,18 @@ dws doc +create --name "<文档名>" --content @body.json --doc-format jsonml --
|
||||
- `status=unknown`:服务端可能已经提交;先定位并读取文档,禁止自动重试。
|
||||
- 没有真实 `nodeId` 或写回执时,禁止声称“已创建”。
|
||||
|
||||
## 创作与执行策略
|
||||
|
||||
采用 Plan → Execute → Observe → Iterate,将循环放在本地内容与定点修正上,不把长文拆成一串远程创建/追加:
|
||||
|
||||
1. **Plan**:明确受众、目的、范围和结构;正文由一个主上下文串行维护,避免按章节并行生成造成重复、矛盾和语气漂移。
|
||||
2. **Draft**:短内容可直接传;多行、长文或含特殊字符时先形成 cwd 内相对文件。用户未要求富结构时优先 Markdown;只有样式/引用/嵌套结构确有必要时才用 JSONML。
|
||||
3. **Execute**:只调用一次 `+create`。DWS Runtime 会分片长 Markdown、记录每步并回读验证;Agent 不采用“先骨架、再逐节远程插入”流程,以减少网络次数、顺序错误和 commit-unknown 面。
|
||||
4. **Observe**:先检查回执中的 `nodeId`、`verified`、`steps` 和失败状态。回执已证明完整时不重复拉全文;需要内容质量验收时,只用 `+fetch --scope section/keyword` 读取待检查部分。
|
||||
5. **Iterate**:后续修正复用同一 `nodeId`,按 [`doc-update.md`](doc-update.md) 做最小 block/文本修改,禁止重新创建整篇。
|
||||
|
||||
交付前检查标题是否重复、段落是否连贯、编号是否统一;只有真实行列数据才使用表格,富组件服务于理解而不是装饰。明确字数要求时应在写入前完成本地统计,不能凭模型估算宣称达标。
|
||||
|
||||
## 高级通道
|
||||
|
||||
只有 shortcut 未公开所需的底层参数或需要原始响应时,才读取精确 leaf Schema 后使用 `dws doc create`。不要因为熟悉旧参数就默认退回原子命令,也不要使用已删除的 Python 创建脚本。
|
||||
|
||||
@@ -8,14 +8,15 @@ dws doc +import --file ./report.docx --folder <FOLDER_ID> --format json
|
||||
dws doc +import --file ./notes.md --workspace <WORKSPACE_ID> --name "会议纪要" --format json
|
||||
```
|
||||
|
||||
`+import` 一次完成创建会话、上传、确认转换和轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。
|
||||
`+import` 一次完成创建会话、上传、确认转换和终态轮询。支持 `doc/docx/xls/xlsx/md/txt/xmind/mark`,文件大小上限 20MB。转换成功回执包含 `success=true`、`taskId`、`documentUrl`、`documentName` 和 `documentType`,不包含 `status` 或 `steps`;成功返回即表示本次内部轮询已到终态。超时或中断时保留错误中的 `taskId`,只查询原任务。
|
||||
|
||||
## 本地文件边界
|
||||
|
||||
- `--file` 只接受当前工作目录内已存在的相对路径;禁止绝对路径、`..` 或符号链接逃逸。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录,两者都有时优先 `--folder`。
|
||||
- `--folder` 与 `--workspace` 都是可选位置;都不传时导入默认根目录。通常只提供一个;当前 Runtime 在两者同时出现时会一并传给服务端,未声明客户端优先级,不要依赖 `--folder` 覆盖 `--workspace`。
|
||||
- CLI 负责上传和格式转换。不要先用 Python/Office 库解析文件,不要安装本地依赖来伪造在线导入结果,也不要手写 HTTP 上传。
|
||||
- 白名单外格式(如 HTML/PDF)自动改走原文件上传,返回 `fallback=upload`、`converted=false`;不得报告成已经转换为可编辑在线文档。
|
||||
- “在线改/协作编辑/转在线文档”属于导入转换;“存着/归档/保留原文件/不要转换”属于 `dingtalk-drive` 纯上传。目标为文档空间时,纯上传使用 `drive upload --workspace <WORKSPACE_ID>`,不要因容器叫“文档空间”就误报为在线文档。
|
||||
|
||||
## 失败处理
|
||||
|
||||
|
||||
@@ -16,10 +16,22 @@ dws doc +media-download --node <DOC_ID> --resource-id <RESOURCE_ID> --output ./d
|
||||
dws doc +media-preview --node <DOC_ID> --resource-id <RESOURCE_ID> --format json
|
||||
```
|
||||
|
||||
## 封面与背景 Shortcut
|
||||
|
||||
```bash
|
||||
dws doc +resource-update --node <DOC_ID> --file ./cover.png --format json
|
||||
dws doc +resource-download --node <DOC_ID> --output ./cover.png --format json
|
||||
dws doc +resource-delete --node <DOC_ID> --format json
|
||||
dws doc +background-update --node <DOC_ID> --color "#E8F2FE" --format json
|
||||
dws doc +background-delete --node <DOC_ID> --format json
|
||||
```
|
||||
|
||||
封面不是正文媒体 block;背景仅接受 `#RRGGBB` 纯色。设置/清除后用一次 `+inspect --include-style` 验证,禁止为这些已知能力查询 shortcut Catalog。
|
||||
|
||||
## 稳定 ID 与结果
|
||||
|
||||
- `resourceId`、`blockId`、`nodeId` 必须来自真实 media/block 返回,不能从标题或本地文件名猜测。
|
||||
- 插入后保留 `resourceId` 和插入结果;下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 插入成功回执在 `data.blockId` 返回已回读验证的媒体块 ID;后续定位只复用该 `blockId`。回执不提供相邻空块字段,不得据此猜测或自动删除其他块。插入回执成功后禁止重传媒体。下载必须检查 `localPath`、`sizeBytes > 0`。
|
||||
- 下载输出只接受工作目录内相对路径,默认 no-clobber。
|
||||
- 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。
|
||||
|
||||
|
||||
@@ -24,10 +24,24 @@ dws doc +fetch --node <DOC_ID> --scope keyword --keyword "风险|结论" --conte
|
||||
|
||||
只有需要块 ID、revision 或 JSONML 保真结构时才提高 `--detail`;先读取最小必要范围,避免把整篇大文档放入上下文。
|
||||
|
||||
整篇读取使用默认 scope 或 `--scope full`;`full` 不是关键词,禁止写成 `--keyword full`。
|
||||
|
||||
## 最小读取漏斗
|
||||
|
||||
按用户已经给出的线索选最短路径,不先拉全文:
|
||||
|
||||
1. 已知稳定 ID/URL,且任务确实涉及整篇:直接默认 `full + simple`,一次返回 Markdown。
|
||||
2. 用户给出具体术语、错误码或同义词:直接 `keyword`;`foo|bar` 是 OR,`context-before/after` 是字符数。该模式会缩小 Agent 输出与 token,但当前 Runtime 仍需取得正文后做本地投影,不把它误述为减少上游传输。
|
||||
3. 用户指向某章但没有 block ID:先 `outline --detail with-ids`,再用返回的真实标题 ID 执行 `section`。通常两次小结果比反复处理整篇更稳定。
|
||||
4. 已知起止 block:直接 `range`;只找表格、图片等结构时用 `tags`。
|
||||
5. 只有确需全文总结、全局一致性检查或整篇保真改写时,才读取完整内容。
|
||||
|
||||
`simple` 用于阅读;`with-ids` 用于定位下一次写操作;`full` 只用于必须保留样式、引用或 JSONML 结构的编辑。局部结果只能证明所选范围,不得据此声称已检查整篇。
|
||||
|
||||
## 后续路由
|
||||
|
||||
- 读后修改:把稳定的 `nodeId` 交给 [`doc-update.md`](doc-update.md) 的 `+update` 或 `+checkpoint-update`。
|
||||
- 附件/图片:先用 [`doc-media.md`](doc-media.md) 的 `+media-list` 取得稳定 `resourceId`,再下载;不要复用正文中的临时签名 URL。
|
||||
- 富结构专家编辑:确实需要原始 JSONML 或 shortcut 未公开的参数时,先读取精确 leaf Schema,再使用原子 `doc read`/`doc block`。
|
||||
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档。
|
||||
禁止把原子 `doc read` 当作默认入口,也不要在读取后无条件整篇回写。筛选结果只用于读取,不能把虚拟 fragment 容器整体写回文档;同一次任务里已拿到稳定 `nodeId` 后,后续调用直接复用,避免再次按标题搜索。
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
普通追加、覆盖和 block 编辑统一使用 `+update`:
|
||||
|
||||
`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。
|
||||
|
||||
```bash
|
||||
dws doc +update --node <DOC_ID> --command append --content "补充说明" --format json
|
||||
dws doc +update --node <DOC_ID> --command append --content @append.md --format json
|
||||
@@ -38,9 +40,29 @@ block ID 必须来自 `+fetch --detail with-ids` 或真实 block 列表,禁止
|
||||
|
||||
`--expected-revision` 只允许 `--command overwrite --doc-format jsonml`。Markdown、append 和 block 接口没有服务端原子 revision 契约,禁止用写前读取模拟乐观锁。
|
||||
|
||||
## 最小改写决策
|
||||
|
||||
| 已知条件 | 推荐路径 | 成本与成功率理由 |
|
||||
|---|---|---|
|
||||
| 用户明确要求在末尾追加 | 直接 `append` | 不为找末尾先拉全文;需要语气衔接时只读末节 |
|
||||
| 已知唯一旧文本与新文本 | 直接 `str_replace --old --new` | 省掉 block 解析;旧文本不唯一时 Runtime 必须失败,不放宽匹配 |
|
||||
| 指定章节但没有 block ID | `+fetch outline` → `+fetch section --detail with-ids` → block 动作 | 两个小读取换取稳定锚点,避免全文 token 和误改相邻章节 |
|
||||
| 已知真实 block ID | 直接 `block_replace/delete/insert_after` | 最小副作用;不改无关 block |
|
||||
| 多处富结构保真修改 | `+fetch --detail full` 后定点 JSONML 更新 | 保留图片、附件、引用、表格和样式;不要从 Markdown 有损重建 |
|
||||
| 整篇重要覆盖 | `+checkpoint-update --mode overwrite` | 自动保存恢复点、执行并回读;普通 overwrite 只用于明确不需恢复点的场景 |
|
||||
|
||||
同一篇文档的正文由一个主上下文串行维护:Plan(确定最小变更)→ Execute(一次写)→ Observe(先消费回执)→ Iterate(只修未达标部分)。不要按章节并行写同一文档,也不要每次迭代都重新读取全文或 Schema。
|
||||
|
||||
## Block ID 生命周期与保真
|
||||
|
||||
- `block_replace` 成功后 Runtime 使用同一 `blockId` 回读验证,该 ID 可继续作为锚点;验证失败时先局部 `+fetch` 核对现状。`block_delete` 成功后旧 ID 失效,不得继续复用。
|
||||
- `block_insert_after` / `block_copy_insert_after` 后,原锚点通常仍可识别,但新 block 的 ID 必须来自真实返回或局部回读,禁止按顺序猜测。
|
||||
- `str_replace` 的简单行内替换通常不要求重新取 ID;若后续依赖块结构,仍以局部回读为准。
|
||||
- 从 Markdown 读取后覆盖整篇可能丢失图片、附件、@人/@文档、评论锚点、表格样式和嵌套块。只改局部时使用 block 手术;确需整篇保真改写时使用 `full` JSONML,并以 `--expected-revision` 防止覆盖并发修改。
|
||||
|
||||
## 确认与验证
|
||||
|
||||
- 执行前从精确 leaf Schema 消费 `confirmation`;需要确认时先 `--dry-run`/询问,用户确认同一参数后追加 `--yes`,禁止先失败探测门禁。
|
||||
- `+update` 与 `+checkpoint-update` 当前都要求用户确认。目标、动作、内容范围或参数变化后必须重新确认;只有发现本地文档与 live leaf 漂移时才查一次精确 Schema,禁止每次写入都重复发现。
|
||||
- `+update` 已负责回读验证。除非结果为 `unknown` 或任务需要额外结构验收,不要再做一次全篇读取。
|
||||
- `doc_write_verification_failed` 表示写入已经发生,必须先读取现状;禁止直接重复写入。
|
||||
- `partial_success` 只恢复未完成步骤,不能重放成功步骤。
|
||||
|
||||
@@ -11,11 +11,13 @@ metadata:
|
||||
|
||||
# 钉钉待办 Skill
|
||||
|
||||
## 前置条件 — 执行操作前必读
|
||||
## 执行契约
|
||||
|
||||
> **CRITICAL — 执行任何 `dws` 操作前,MUST 先用 Read 工具完整读取 [`dingtalk-shared`](../dingtalk-shared/SKILL.md)。**该轻量文件包含全局执行契约、安全底线及 shared references 的按需加载导航;不要预加载其全部 references。
|
||||
|
||||
> 命令参考:[todo.md](references/todo.md);剧本:[02-task.md](references/02-task.md)。
|
||||
- 只用 `dws` 操作钉钉待办,命令统一加 `--format json`,并按结构化业务返回判断结果。
|
||||
- 已知路线直接执行;仅在 leaf 参数或安全语义不确定时查精确 Schema,在 flag 不确定时查精确 Help。不要先枚举整个 Catalog,也不要连续猜命令。
|
||||
- 后续 ID 必须来自本次真实返回;零匹配、多匹配或类型不明时停止并消歧。
|
||||
- 写操作遵循最终 Runtime gate。需要确认时先说明对象、动作和影响,用户确认后才追加 `--yes`;不要把 `--yes` 写进存储示例。
|
||||
- 写后必须核验。非幂等写超时、缺少稳定 ID 或读回失败时先对账,禁止盲目重放。
|
||||
|
||||
<!-- VISIBLE_SHORTCUTS_START -->
|
||||
## Shortcuts(无专用脚本/recipe 时优先)
|
||||
@@ -44,80 +46,50 @@ metadata:
|
||||
| `dws todo +update` | write | 更新待办并读回验证 |
|
||||
<!-- VISIBLE_SHORTCUTS_END -->
|
||||
|
||||
## 意图表
|
||||
## 路由优先级
|
||||
|
||||
| 用户说 | 命令 |
|
||||
|--------|------|
|
||||
| "建一条待办给张三" | `dws todo task create --title "<标题>" --executors <userId>` |
|
||||
| "较高 / 高优先级待办" | `dws todo task create ... --priority 30`(10低/20普通/30较高/40紧急) |
|
||||
| "紧急 / 最高优先级 / 立即处理" | `dws todo task create ... --priority 40` |
|
||||
| "循环待办(每天)" | `dws todo task create ... --due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"` |
|
||||
| "批量建待办" | 按 SOP-4 逐条创建、收集 `taskId` 并批量回读 |
|
||||
| "今天 / 本周未完成待办" | `python scripts/todo_daily_summary.py [today\|tomorrow\|week]` |
|
||||
| "逾期待办" | `python scripts/todo_overdue_check.py` |
|
||||
| "标记完成 / 重开" | `dws todo task done --task-id <taskId> --status true\|false` |
|
||||
| "修改标题/截止时间/优先级" | `dws todo task update --task-id <taskId> ...` |
|
||||
| "删除待办" | `dws todo task delete --task-id <taskId>`(需用户确认) |
|
||||
上面的通用 shortcut 优先规则只适用于单一、完整命中的请求。本 Skill 的实际优先级是:
|
||||
|
||||
## 标准 SOP(必遵流程)
|
||||
1. **组合生命周期 → 原子命令闭环**:同一请求含创建后再列表、更新、完成、提醒、评论、附件、成员、子待办、标签或清理时,先读 [组合流程](references/02-task.md),全程使用 `todo task/comment/tag ...` 原子命令。不要用 `+remind` / `+create` 代替组合流程的第一步,也不要混用两套返回结构。
|
||||
2. **确定性批量/汇总 → 脚本**:批量创建、今天/明天/本周汇总、逾期扫描分别用 bundled scripts。
|
||||
3. **单一意图 → Shortcut**:只在一个 shortcut 已经覆盖完整目标、校验和结果形态时直接使用。
|
||||
4. **未知低频能力 → 精确 Reference/Help**:只读当前操作的小节,不要枚举后试错。
|
||||
|
||||
> 命中以下意图**必须**按对应 SOP 顺序执行;**禁止**跳步、替换命令、编造 taskId。每条命令必须带 `--format json`。创建/完成/删除后**必须**回读验证,不要凭创建返回或口头计划就结束。
|
||||
## 高频路线
|
||||
|
||||
### SOP-1 建待办(create-todo)
|
||||
| 用户意图 | 首选入口 | 边界 |
|
||||
|---|---|---|
|
||||
| 给自己创建一条普通待办 | `dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json` | `--at` 是截止时间,不是独立提醒 |
|
||||
| 按姓名给一人/多人指派一条待办 | `+assign` / `+assign-multi` | 任一姓名不唯一就停止,不能猜 `userId` |
|
||||
| 已有 `userId`,只创建并回读一条待办 | `dws todo +create --title "<标题>" --executors <USER_ID> ... --format json` | 结果必须含稳定 `taskId` 且读回一致 |
|
||||
| 创建后还要做其他操作 | `dws todo task create ... --format json` | 从 `result.taskId` 进入同一原子命令闭环 |
|
||||
| 按状态、优先级、角色、日期或页码枚举 | `dws todo task list ... --format json` | `--status false/true`;`hasMore=true` 继续翻页 |
|
||||
| 当前组织我的待办 / 与我相关的全部待办 | `+get-my-tasks` / `+get-related-tasks` | 后者是创建人、执行人、参与人并集 |
|
||||
| 按标题关键词定位 / 已知 ID 查详情 | `+search --query ...` / `+get --task-id ...` | 零个或多个候选时停止消歧 |
|
||||
| 已知 ID 完成、重开、更新 | `+complete` / `+reopen` / `+update` | shortcut 会做状态检查或读回核验 |
|
||||
| 今天到期 / 逾期 | `+due-today` / `+overdue` | 空集合也是成功结果 |
|
||||
| 设置或清除独立提醒 | `+reminder` | 上游无提醒查询接口,只能报告写回执,不能声称读回 |
|
||||
|
||||
**触发**:建待办/任务提醒/指派任务/TODO。
|
||||
## 组合任务闭环
|
||||
|
||||
1. **解析执行者(必须)**:指定姓名 → `dws aisearch person --query "<姓名>" --dimension name --format json` 取 `userId`;未指定 → `dws contact user get-self --format json` 取当前用户 `userId`;多人逐个搜索后英文逗号拼接。
|
||||
2. **执行(必须)**:`dws todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40> --format json`;有截止时间加 `--due "<ISO>"`;循环待办加 `--due "<首次截止ISO>" --recurrence "DTSTART:<UTC>\nRRULE:FREQ=DAILY;INTERVAL=1"`。
|
||||
3. **验证(必须)**:从返回取 `taskId`/`todoTaskId`,立即 `dws todo task get --task-id <taskId> --format json` 回读。
|
||||
1. 执行前列出用户要求的全部资源动作及顺序;同一链路使用同一个 profile。
|
||||
2. 原子创建必须先取得执行人:未指定执行人用 `dws contact me --format json`;指定姓名用 `dws aisearch person --query "<姓名>" --dimension name --format json` 并唯一匹配。
|
||||
3. 创建后只从 `result.taskId` 提取稳定 ID。后续评论、附件和标签编号分别来自 `comment list`、`task list-attachment`、`tag create/list` 的真实返回。
|
||||
4. 每次写后使用对应 read/list 核验;提醒例外,只保留终端写回执。最后仅清理本次创建且已经记录 ID 的对象。
|
||||
|
||||
**禁止**:跳过执行者解析直接传姓名、用 `task detail` 取详情(正确是 `task get`)、创建后不回读。
|
||||
## 关键约束
|
||||
|
||||
### SOP-2 查询待办(query-todo)
|
||||
- 公开命令统一使用 `--task-id`;不要在新命令中使用隐藏兼容别名 `--id` / `--ids`。
|
||||
- 优先级:低=10、普通=20、较高/高/重要=30、紧急/最高/P0=40。
|
||||
- `--due` 与 `+remind --at` 表示 deadline;独立 reminder 用 `+reminder` 或原子 `task add-reminder`。
|
||||
- `task list` 用 `--status`,不要写 `--done`;详情命令是 `task get`,不存在 `task detail`。
|
||||
- “待办标签”始终使用 `dws todo tag ...`,绝不能解释为 Git tag、文档标签或其他产品标签。
|
||||
- 本地文件作为待办附件时使用 `task add-attachment --file <绝对路径>`;先确认待办存在,不得用上传动作试探权限。
|
||||
- 会后行动项先走 `dingtalk-minutes` 取真实内容;OA 审批走 `dingtalk-misc`;时间块和会议走 `dingtalk-calendar`。
|
||||
|
||||
**触发**:查待办/今天本周待办/未完成/已完成。
|
||||
## 按需参考
|
||||
|
||||
1. **执行(必须)**:`dws todo task list --status false|true --format json`(`false`=未完成、`true`=已完成、不传=全部);`hasMore=true` 必须翻页。
|
||||
2. **摘要脚本(必须)**:今天/本周未完成 → `python scripts/todo_daily_summary.py today|tomorrow|week`;逾期 → `python scripts/todo_overdue_check.py`。
|
||||
3. **详情(必须)**:`dws todo task get --task-id <taskId> --format json`;按主题筛选先 `task list` 再按标题过滤,**禁止**编造主题查询 flag。
|
||||
|
||||
**禁止**:写 `--done true`(用 `--status true`)、编造主题筛选参数。
|
||||
|
||||
### SOP-3 完成 / 重开 / 改 / 删(mutate-todo)
|
||||
|
||||
**触发**:标记完成/重开/改标题截止优先级/删待办。
|
||||
|
||||
1. **执行(必须)**:完成/重开 `dws todo task done --task-id <taskId> --status true|false --format json`;修改 `dws todo task update --task-id <taskId> ...`;删除 `dws todo task delete --task-id <taskId>`(**必须**先与用户确认)。
|
||||
2. **验证(必须)**:`task done`/`update` 后用 `task get` 或对应 `task list --status ...` 回读确认;`delete` 后用 `task get` 确认已不存在或列表已移除。
|
||||
|
||||
**禁止**:未确认就删除、用 `update --done`(首选 `task done --status`)、改动后不回读。
|
||||
|
||||
### SOP-4 批量建待办(batch-create)
|
||||
|
||||
**触发**:批量建待办/一次建多条。
|
||||
|
||||
1. **解析(必须)**:执行者姓名先批量解析成真实 `userId`;单批最多 30 条。
|
||||
2. **执行(必须)**:对每条待办执行 `dws todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40> [--due "<ISO>"] --format json`,逐条收集返回的 `taskId`/`todoTaskId`。可并行执行,但不得丢失“输入条目 → taskId”对应关系。
|
||||
3. **验证(必须)**:对全部新建 `taskId` 执行 `dws todo task get --task-id <taskId> --format json` 回读;多 ID 按共享并行规则处理,全部成功后才能报告批量创建完成。
|
||||
|
||||
**禁止**:只统计创建命令退出码、不保留 taskId、创建后不回读、在执行者位置传姓名。
|
||||
|
||||
## 参数硬约束
|
||||
|
||||
- 任务详情只用 `dws todo task get --task-id <taskId>`;不要写 `task detail`。
|
||||
- 完成状态首选 `dws todo task done --task-id <taskId> --status true|false`;若用 `update`,也必须是 `--task-id` + `--done true|false`。
|
||||
- 查询列表完成状态用 `dws todo task list --status false|true --format json`。不要写 `--done true` 作为可见参数,虽然兼容但不作为推荐写法。
|
||||
- `--id` / `--ids` 是隐藏兼容别名,文档和生成命令统一写 `--task-id`,减少模型漂移。
|
||||
- 优先级映射:低=10,普通=20,较高/高/重要=30,紧急/最高/P0/马上处理=40;不要把"较高"写成 40。
|
||||
- 截止时间必须是 ISO-8601。相对日期按当前日期计算;例如周五说"下周二"就是紧接下一个自然周的周二,不要再加一周。
|
||||
- 创建、标记完成、重开、删除后必须 `task get` 或对应 `task list --status ...` 验证,不要只凭创建返回或口头计划结束。
|
||||
- 所有 dws 命令带 `--format json`。
|
||||
|
||||
## 跨产品协作
|
||||
|
||||
- 执行人是人名 → 先用 `dingtalk-aisearch` 拿 `userId`
|
||||
- 会后从听记自动建待办 → 切到 `dingtalk-minutes`
|
||||
- 项目进度汇总写文档 → 切到 `dingtalk-doc`
|
||||
## 局部意图与短流程
|
||||
|
||||
- [局部意图消歧](references/intent-guide.md);[短流程](references/lite-recipes.md)。
|
||||
- [单步与短流程](references/lite-recipes.md)
|
||||
- [组合生命周期与动态 ID 传递](references/02-task.md)
|
||||
- [局部意图消歧](references/intent-guide.md)
|
||||
- [完整原子命令参考](references/todo.md)
|
||||
|
||||
@@ -1,19 +1,61 @@
|
||||
# 任务管理
|
||||
# Todo 组合生命周期
|
||||
|
||||
> **SKILL.md** 中 #2 仅内联 **lite**:`create-todo`、`todo-query-ops`。其中 `todo-query-ops` 统一覆盖 list/get/complete/reopen/topic-filter。下列 recipe 已迁出速查表,命中时读本文件对应行。重型 **full** 见下表「行动指南」。命令细节见 [todo.md](./todo.md)。
|
||||
当一个请求包含两个以上资源动作,特别是“创建 → 操作 → 查看 → 清理”时,使用本文件。组合链路统一走原子命令,不把 shortcut 与原子返回结构混在一起。
|
||||
|
||||
## Recipe 速查(非 SKILL lite)
|
||||
## 通用骨架
|
||||
|
||||
| Recipe | 步骤(命令均须 `--format json`,下略) |
|
||||
|--------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| `create-priority-todo` | 1. 确定执行者(同 [SKILL.md](../SKILL.md) 中 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <10/20/30/40>`(可选 `--due "<截止ISO>"`;10低/20普通/30较高/40紧急)→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读标题、执行者、优先级和截止时间 |
|
||||
| `create-recurring-todo` | 1. 确定执行者(同 `create-todo` 步骤 1)<br>2. `todo task create --title "<标题>" --executors <userId> --due "<首次截止ISO>" --priority <10/20/30/40> --recurrence "DTSTART:<UTC时间>\nRRULE:FREQ=DAILY;INTERVAL=1"`(`--due` 必填;仅支持按天循环,见 [todo.md](./todo.md))→ 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读循环规则和任务字段 |
|
||||
| `reschedule-todo` | 1. `todo task list --status false` → 取 `todoTaskId`<br>2. `todo task update --task-id <todoTaskId> --due "<新截止时间>"` |
|
||||
1. 先列出完整动作序列和需要传递的 ID,不要边执行边发现路线。
|
||||
2. 未指定执行人时运行 `dws contact me --format json`;指定姓名时运行 `dws aisearch person --query "<姓名>" --dimension name --format json`,唯一匹配后取 `userId`。
|
||||
3. 创建父待办:
|
||||
|
||||
## Full / 组合(固定路线)
|
||||
```bash
|
||||
dws todo task create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] [--recurrence "<规则>"] --format json
|
||||
```
|
||||
|
||||
| Recipe | 行动指南(固定路线) |
|
||||
|--------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| generate-progress-report | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行<br>2. 交叉比对各源数据<br>3. `doc create --name "<报告名>" --content "<报告内容>"` |
|
||||
| batch-create-todo | 1. 按[「多源并行采集」](recipes/conventions.md#多源并行采集公共模式)执行 → 从结果提取任务条目<br>2. 每条:`aisearch person --query "<姓名>" --dimension name` → 取真实 `userId`;同名时先消歧<br>3. 逐条执行 `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>`,从每次响应收集真实 `todoTaskId`;单批超 30 条须用户确认<br>4. 对全部 `todoTaskId` 并行执行 `todo task get --task-id <todoTaskId>`,逐项核对标题、执行者、优先级和截止时间;不能只以退出码或创建响应作为成功证据 |
|
||||
| assign-and-notify | 1. `aisearch person --query "<姓名>" --dimension name` → 取 `userId`<br>2. `todo task create --title "<标题>" --executors <userId> --priority <10/20/30/40>` → 取 `todoTaskId`<br>3. `todo task get --task-id <todoTaskId>` 回读任务,确认无误后再通知<br>4. `chat search --query "<群名>"` → 取 `openConversationId` → `chat message send --conversation-id <openConversationId> --content "<通知内容>"` |
|
||||
4. 只从成功响应的 `result.taskId` 取 ID,立即 `dws todo task get --task-id <TASK_ID> --format json` 核验。
|
||||
5. 按下表执行后续动作;每一步都复用真实 ID,并用对应读取命令核验。
|
||||
6. 只清理本次创建且已记录 ID 的对象;删除类操作先走 Runtime 确认门。删除后 `task get` 不存在或列表移除才算清理完成。
|
||||
|
||||
## 原子路线表
|
||||
|
||||
| 意图 | 写命令 | 核验 / ID 来源 |
|
||||
|---|---|---|
|
||||
| 按状态/优先级/角色/日期查询 | `task list --status ... --priority ... --role-types ... --plan-finish-date-start ... --plan-finish-date-end ...` | 遍历 `result.todoCards[]`;`hasMore=true` 时递增 `--page` |
|
||||
| 更新标题/优先级/截止时间 | `task update --task-id <TASK_ID> ...` | `task get --task-id <TASK_ID>` 逐字段核验 |
|
||||
| 完成 / 重开 | `task done --task-id <TASK_ID> --status true|false` | `task get` 或对应状态的 `task list` |
|
||||
| 创建子待办 | `task create-sub --parent-id <PARENT_ID> --title "<标题>" --executors <USER_ID>` | 取响应 `result.taskId`;`task list-sub --task-id <PARENT_ID>` |
|
||||
| 增删执行人 | `task add-executor --task-id <TASK_ID> --executors <USER_ID>` / `task remove-executor --task-id <TASK_ID> --executors <USER_ID>` | `task get`;只移除本次明确添加的人 |
|
||||
| 增删参与人 | `task add-participant --task-id <TASK_ID> --participants <USER_ID>` / `task remove-participant --task-id <TASK_ID> --participants <USER_ID>` | `task get`;执行人与参与人不可混用 |
|
||||
| 添加评论 | `comment add --task-id <TASK_ID> --content "<内容>"` | 从 `comment list` 的 `result.comments[].id` 取真实评论 ID 并核对内容 |
|
||||
| 删除评论 | `comment delete --task-id <TASK_ID> --comment-id <COMMENT_ID>` | 再次 `comment list` 确认目标不存在 |
|
||||
| 上传附件 | `task add-attachment --task-id <TASK_ID> --file <绝对路径>` | `task list-attachment` 取真实 `attachmentId` 并核对文件名 |
|
||||
| 移除附件 | `task remove-attachment --task-id <TASK_ID> --attachment-id <ATTACHMENT_ID>` | 再次 `task list-attachment` |
|
||||
| 添加截止前提醒 | `task add-reminder --task-id <TASK_ID> --base-time dueTime --due-date-offset -30` | 只有终端写回执;待办必须已有截止时间 |
|
||||
| 添加独立提醒 | `task add-reminder --task-id <TASK_ID> --base-time customTime --reminder-time-stamp "<提醒ISO>"` | 只有终端写回执 |
|
||||
| 替换/清空提醒 | `task reset-reminder --task-id <TASK_ID> [--reminder-rules '<JSON数组>']` | 不传规则即清空;不能声称已读回规则 |
|
||||
| 创建/列出标签 | `tag create --name "<名称>"` / `tag list` | 创建前后比较 `result.userTags[]`,只接受唯一新增的 `code` |
|
||||
| 关联标签 | `tag add --task-id <TASK_ID> --tag-codes <TAG_CODE_1>[,<TAG_CODE_2>]` | `task get` 或写回执;最多两个标签 |
|
||||
| 改名标签 | `tag update --user-tags '[{"code":"<TAG_CODE>","name":"<新名称>"}]'` | `tag list` 核对同一 `code` 的名称 |
|
||||
| 删除标签定义 | `tag delete --tag-codes <TAG_CODE>` | `tag list` 确认标签定义不存在 |
|
||||
| 删除待办 | `task delete --task-id <TASK_ID>` | `task get` 不存在或对应列表已移除 |
|
||||
|
||||
所有表中命令都加前缀 `dws todo` 和后缀 `--format json`。
|
||||
|
||||
## 动态 ID 账本
|
||||
|
||||
| ID | 只允许来自 | 禁止来源 |
|
||||
|---|---|---|
|
||||
| `taskId` | `task create/create-sub/get/list` 的业务返回 | 标题、URL、展示序号、其他 case |
|
||||
| `commentId` | 同一 `taskId` 的 `comment list` 返回 `result.comments[].id` | 评论文本或猜测 |
|
||||
| `attachmentId` | 同一 `taskId` 的 `task list-attachment` 返回 `attachments[].attachmentId` | 文件名或本地路径 |
|
||||
| `tagCode` | `todo tag create/list` 返回 `result.userTags[].code` | 标签名或 Git tag |
|
||||
| `userId` | `contact me` 或 `aisearch person` 的唯一匹配 | 姓名、手机号片段、其他 profile |
|
||||
|
||||
“待办标签”只能调用 `dws todo tag ...`;禁止运行 `git tag`。本地待办附件只能调用 `dws todo task add-attachment`,不要改走 Drive。
|
||||
|
||||
## 失败与恢复
|
||||
|
||||
- `unknown command/flag`:查该 leaf 的 `--help` 后最多修正一次,不要轮询相似命令。
|
||||
- 创建、评论等非幂等写超时或返回不明:保留“可能已提交”,先按标题/父 ID 查询对账,不自动重试。
|
||||
- 提醒接口没有查询能力:成功时只报告服务端接受写入;失败时保留原错误。
|
||||
- 任一步失败后仍要按账本清理已创建的临时对象;未取得稳定 ID 的对象不得猜 ID 清理。
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
# todo 局部意图消歧
|
||||
# Todo 局部意图消歧
|
||||
|
||||
本文件从单 Skill `intent-guide.md` 拆分而来,仅保留与本产品相关的跨产品消歧规则。
|
||||
| 用户说 | 应该用 | 不要用 | 边界 |
|
||||
|---|---|---|---|
|
||||
| “帮我记一下明天要做的事” | `todo +remind` | `doc` | 这是个人待办;`--at` 写截止时间 |
|
||||
| “给自己留一个明天下午的时间块” | `calendar event create` | `todo` | 时间块属于日历事件 |
|
||||
| “明早 9 点提醒我提交周报” | 先创建待办,再用 `todo +reminder --base-time customTime --at ...` | 把 `+remind --at` 当提醒 | 独立提醒与截止时间是两个字段 |
|
||||
| “截止前 30 分钟提醒” | `todo +reminder --base-time dueTime --due-date-offset -30` | `calendar` | 待办必须先有截止时间 |
|
||||
| “每天重复提醒我” | `todo task create --due ... --recurrence ...`,必要时再加 reminder | 只写 reminder | recurrence 管重复待办;reminder 管单条待办提醒 |
|
||||
| “创建/修改/删除待办标签” | `dws todo tag ...` | `git tag`、文档标签 | 标签名不是 `tagCode`,后续使用真实返回 |
|
||||
| “提交日报/周报” | `dingtalk-misc` | `todo` | 日志产品不是个人待办 |
|
||||
| “审批这个申请” | `dingtalk-misc` | `todo` | OA 审批任务与个人待办不同 |
|
||||
| “把会议行动项建成待办” | 先用 `dingtalk-minutes` 取真实行动项,再进入 Todo | 凭标题猜行动项 | 来源证据归听记,任务对象归 Todo |
|
||||
|
||||
| 用户说... | 真实意图 | 应该用 | 不要用 | 理由 |
|
||||
|---|---|---|---|---|
|
||||
| "帮我记一下明天要做的事" | 创建个人待办 | `todo` | `doc` | 个人待办提醒,非文档内容 |
|
||||
| "给自己留一个明天下午的时间块/建个个人日程" | 创建个人日程 | `calendar event create` | `todo` | 个人 schedule 仍属于日历事件,不是待办 |
|
||||
| "明早 9 点提醒我提交周报" | 创建个人待办,但需先声明 reminder 边界 | `todo` | `calendar` | todo 当前只支持 dueTime 截止时间,不支持独立精确 reminder |
|
||||
| "帮我创建一个待办提醒" | 个人待办 | `todo` | `report` | 个人任务提醒,不是日志汇报 |
|
||||
| "把最近几次关于XX的会议汇总成报告" | 按主题汇总多次听记 | #5 generate-topic-report | #7 meeting-followup | #7 是单次会议听记跟进;多次会议按主题汇总属于工作汇报 |
|
||||
提醒写入目前没有对应查询接口,成功响应只能证明服务端接受了写请求,不能声称已经读回核验规则。
|
||||
|
||||
@@ -1,151 +1,57 @@
|
||||
# todo Lite Recipe
|
||||
# Todo 单步与短流程
|
||||
|
||||
本文件从单 Skill `lite-recipes.md` 拆分而来,仅保留与本产品相关的轻量流程。
|
||||
只在请求是单一 Todo 意图时使用本文件;创建后还要继续操作资源时,改读 [02-task.md](02-task.md)。
|
||||
|
||||
## #2 任务管理
|
||||
## 创建一条待办
|
||||
|
||||
### create-todo
|
||||
|
||||
1. 确定执行者:指定姓名 → `aisearch person --query "<姓名>" --dimension name` → `userId`;未指定 → `contact user get-self` → `userId`;多人 → 逐个搜索逗号拼接。
|
||||
2. 创建:`todo task create --title "<标题>" --executors <userId>[,<userId2>...] --priority <优先级>`(可选 `--due "<截止ISO>"`)→ `todoTaskId`
|
||||
|
||||
### todo-query-ops
|
||||
|
||||
- 查询:`todo task list [--status false|true]`(不传=全部)
|
||||
- 详情:`todo task get --task-id <id>`
|
||||
- 完成/重开:`todo task done --task-id <id> --status <true|false>`
|
||||
- 按主题筛选:list 后按标题关键词过滤
|
||||
|
||||
|
||||
## #7 听记与会后
|
||||
|
||||
> 产品命令完整参考见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。full recipe 见 [07-minutes.md](../../dingtalk-minutes/references/07-minutes.md)。
|
||||
|
||||
### minutes-query(查询与获取)
|
||||
|
||||
> **scope 选择铁律(P2 真实 badcase)**:`list` 后的 scope 决定查询范围,最高频误判是把"我能访问的所有听记"错选成 `mine`:
|
||||
> - `mine` = **仅我自己创建/发起**的听记(范围最窄)
|
||||
> - `shared` = **仅他人共享给我**的听记
|
||||
> - `all` = **我可访问的全部**(= mine ∪ shared,范围最广)
|
||||
> - **判定口诀**:query 含"访问/权限/可见/能看到/所有/我的"等覆盖范围语义 → 一律走 `all`;**仅当**明确说"我创建的/我发起的/我录的" → 才走 `mine`。**不要因为句子里有"我"字就退化成 `mine`。**
|
||||
> - 错误:`我能访问的所有听记` → `list mine`(漏掉共享给我的,判定不通过)
|
||||
> - 正确:`我能访问的所有听记` → `dws minutes list all --format json`
|
||||
|
||||
> **选对象铁律(0605 P2 EDD badcase 提炼,命令对了但选错听记 = 整任务失败)**:list/搜索拿到结果后,必须按语义精准锁定目标听记,详见 [minutes.md](../../dingtalk-minutes/references/minutes.md)「选对象铁律 S1~S6」。速记:
|
||||
> - **S1 跨组织汇总**:以 list 返回的 `taskUuid + title + organizationName` 三元组为准逐条照抄,组织与听记不可张冠李戴。
|
||||
> - **S2 "最近一次某类会议"**:先 `--query "<主题词>"`(如周会)过滤出该类,再在候选里取时间最新;主题匹配优先级高于时间。
|
||||
> - **S3 比时长最长**:必须读 `durationMicros` 字段做数值比较,禁止凭印象/标题猜,口头结论与操作的 taskUuid 须自洽。
|
||||
> - **S4 内容为空**:锁定 taskUuid 后所有 get/update 复用同一 id;某字段为空就如实说,**禁止偷偷切换到另一条听记**。
|
||||
> - **S5 模糊日期匹配不到**:日期可能是"会议主题日期"而非"创建日期",按标题关键词搜,精确日期没命中就放宽 ±7 天/同主题候选请用户确认,**禁止直接报"找不到"**。搜索回退策略:① 先 `--query "<主题关键词>"` 不带日期搜 → ② 若结果过多则加 `--start/--end` 扩大到 ±7 天 → ③ 列出候选让用户确认。
|
||||
> - **S6 给标题没给 id**:必须先 `list all --query "<标题关键词>"` 定位 taskUuid 再 update/get,禁止凭记忆直接填 `--id` 跳过定位。
|
||||
|
||||
**列表查询**(`list` 后**必须**跟 scope:`mine`/`shared`/`all`,默认补 `all`):
|
||||
给自己:
|
||||
|
||||
```bash
|
||||
# 我可访问的所有听记(默认)
|
||||
dws minutes list all --format json
|
||||
# 按关键词服务端搜索(严禁全量拉取后本地 grep)
|
||||
dws minutes list all --query "周会" --format json
|
||||
# 按时间范围筛选(ISO-8601 格式)
|
||||
dws minutes list mine --start "2026-05-01T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 关键词 + 时间组合
|
||||
dws minutes list all --query "需求评审" --start "2026-05-25T00:00:00+08:00" --end "2026-05-25T23:59:59+08:00" --format json
|
||||
# 限制条数
|
||||
dws minutes list mine --limit 5 --format json
|
||||
# 共享给我的听记
|
||||
dws minutes list shared --query "ROI" --format json
|
||||
dws todo +remind --task "<标题>" [--at "<截止ISO>"] --format json
|
||||
```
|
||||
|
||||
| 参数 | 说明 |
|
||||
|------|------|
|
||||
| `--query "<关键词>"` | 服务端关键词搜索 |
|
||||
| `--start "<ISO-8601>"` | 开始时间 |
|
||||
| `--end "<ISO-8601>"` | 结束时间 |
|
||||
| `--limit <N>` | 每页条数,默认 10(`--max` 为兼容别名) |
|
||||
| `--cursor "<token>"` | 分页 token,首页留空(`--next-token` 为兼容别名) |
|
||||
|
||||
**获取详情**:
|
||||
|
||||
- 批量基础信息:`minutes get batch --ids <uuid1,uuid2,...>`
|
||||
- 单篇摘要:`minutes get summary --id <taskUuid>`
|
||||
- 转写原文(自动翻页):`minutes get transcription --id <taskUuid>`(返回 `nextToken` 时用 `--next-token <token>` 继续)
|
||||
- 关键词:`minutes get keywords --id <taskUuid>`
|
||||
- 待办事项:`minutes get todos --id <taskUuid>`
|
||||
- 基础信息:`minutes get info --id <taskUuid>`
|
||||
- 音频地址:`minutes get audio --id <taskUuid>`
|
||||
|
||||
> `--id`/`--uuid`/`--task-uuid` 三者等价。推荐 `--id`。
|
||||
|
||||
### minutes-edit(编辑与替换)
|
||||
|
||||
- **替换转写文字**:`minutes replace-text --id <taskUuid> --search "旧文字" --replace "新文字"`
|
||||
- 执行前检查特殊字符(引号/书名号/括号等),若包含先提示用户确认去除
|
||||
- 替换成功后追问是否加热词:`minutes hot-word add --words "新文字"`
|
||||
- **替换发言人**:先统一搜人取得 dingUid → `minutes speaker replace --id <taskUuid> --from "发言人X" --to "姓名" --target-uid <userId>`
|
||||
- 查询 dingUid:`aisearch person --query "姓名" --dimension name --format json` → 取 `userId`
|
||||
- 多个匹配 → 列出候选让用户选;无匹配 → 不带 `--target-uid` 执行
|
||||
- **修改标题**:`minutes update title --id <taskUuid> --title "新标题"`
|
||||
- **修改摘要**:`minutes update summary --id <taskUuid> --content "新内容"`
|
||||
- **热词管理**:`minutes hot-word add --words "词1,词2"` / `minutes hot-word list`
|
||||
- **思维导图**:`minutes mind-graph create --id <taskUuid>` → `mind-graph status --id <taskUuid>` 轮询至完成
|
||||
|
||||
### minutes-tag(标签/分组查询)
|
||||
|
||||
- 查询标签列表:`minutes tag list` → 返回用户在听记页面创建的所有标签/分组(含 tagId 和名称)
|
||||
- 按标签查听记:`minutes tag query --tag-id <tagId> [--limit 20] [--cursor <token>]`
|
||||
- tagId 来自 `tag list` 返回值,不可编造
|
||||
- 支持分页,`--cursor` 传入上一次返回的 nextToken
|
||||
|
||||
**典型链路**:用户说"帮我看看'周会'标签下的听记" →
|
||||
1. `dws minutes tag list --format json` → 按名称匹配找到 tagId
|
||||
2. `dws minutes tag query --tag-id <tagId> --format json`
|
||||
|
||||
### minutes-permission(权限管理)
|
||||
|
||||
- 添加成员:`minutes permission add --ids <uuid1,uuid2> --member-uids <uid1,uid2> --policy 4`
|
||||
- 需先通过 `aisearch person --query "<姓名>" --dimension name` 获取目标 userId
|
||||
- policy:0=不可见 / 1=仅查看 / 2=查看+下载 / 3=查看+下载+编辑 / 4=全部权限
|
||||
- 移除成员:`minutes permission remove --ids <uuid1,uuid2> --member-uids <uid1,uid2>`
|
||||
|
||||
### minutes-upload(音频上传)
|
||||
按姓名指派:
|
||||
|
||||
```bash
|
||||
# 创建上传会话
|
||||
dws minutes upload create --file-name "meeting.mp3" --file-size 61565431 --format json
|
||||
# 上传完成后确认
|
||||
dws minutes upload complete --session-id <sid> --format json
|
||||
# 取消上传
|
||||
dws minutes upload cancel --session-id <sid> --format json
|
||||
dws todo +assign --to "<姓名>" --task "<标题>" --format json
|
||||
dws todo +assign-multi --to "<姓名1>,<姓名2>" --task "<标题>" --format json
|
||||
```
|
||||
|
||||
### 最佳实践案例速查(详见 [minutes.md](../../dingtalk-minutes/references/minutes.md))
|
||||
已经有真实 `userId`:
|
||||
|
||||
| 案例 | 场景 | 正确链路 |
|
||||
|------|------|----------|
|
||||
| 案例 1 | 听记 URL + 创建思维导图 | 提取 taskUuid → `mind-graph create` → `mind-graph status` 轮询;**禁止**走 app-development 或前端库 |
|
||||
| 案例 2 | 替换文字后未引导热词 | 检查特殊字符 → `replace-text` → 追问加热词 `hot-word add` |
|
||||
| 案例 3 | 查听记拉了不必要的转写 | 用户只要列表 → `list` 即可,**不要**自动拉 `get transcription` |
|
||||
| 案例 4 | 拉完转写只输出时间线原文 | 拉完后追问按发言人聚类 → 引导匹配 → 调用 `speaker replace` 写回 |
|
||||
| 案例 5 | 查某人说了什么不引导替换 | 推断发言人 → **用户确认** → 结构化总结 → 引导 `speaker replace` |
|
||||
| 案例 6 | 通讯录+部门+转写三路印证 | Step 3 画像 + Step 4 `aisearch person` 并发 → 置信度 ≥70% → 确认 → 替换 |
|
||||
| 案例 7 | grep 花名误判未参会 | **禁止**在转写文本里 grep 人名判参会;**必须**调 `aisearch person` |
|
||||
| 案例 8 | 听记类 query 不走 dws | **禁止**用 session_search/browser_use/activity:search 替代 dws;模糊请求先 `list mine` |
|
||||
| 案例 9 | 按标签筛选听记 | `tag list` → 按名称匹配 tagId → `tag query --tag-id <tagId>`;**禁止**编造 tagId |
|
||||
```bash
|
||||
dws todo +create --title "<标题>" --executors <USER_ID> [--priority 10|20|30|40] [--due "<截止ISO>"] --format json
|
||||
```
|
||||
|
||||
### 听记取数深度约束(0609 点踩 case 提炼)
|
||||
成功结果必须含稳定 `taskId` 并完成读回。超时、缺少 ID 或 `verified!=true` 时先用搜索/列表对账,禁止重放非幂等创建。
|
||||
|
||||
> 详细说明见 [minutes.md](../../dingtalk-minutes/references/minutes.md)。
|
||||
## 查询与定位
|
||||
|
||||
- **转写原文硬约束**:用户诉求含「聚焦原话/逐字/沟通细节/具体讨论了什么」等词时,**必须先调 `get transcription` 翻页拉全**,禁止仅凭 summary 出稿
|
||||
- **数据源下钻**:听记维度**必须 `get summary`(或 `get transcription`)读正文**,严禁只取标题列表;scope 用 `all`;空时换窗重试或标注
|
||||
- **听记链接解析**:聊天消息中遇到听记链接(`flash_minutes_detail`/`SHANJI`)→ 解析 `minutesId` → 调 `minutes get summary/transcription`,禁止把链接降级为关键词
|
||||
- **忠实性约束**:源数据无某要素(行动项/责任人/数字)时禁止生成;统计字段基于实际取数计数,不得编造
|
||||
- **多源全覆盖**:用户枚举多数据源时每个来源都必须调对应工具;瞬时错误重试;如实声明缺失来源,禁编无来源数字
|
||||
```bash
|
||||
dws todo +get-my-tasks --all --status false --format json
|
||||
dws todo +get-related-tasks --format json
|
||||
dws todo +search --query "<标题关键词>" --format json
|
||||
dws todo +get --task-id <TASK_ID> --format json
|
||||
```
|
||||
|
||||
### 间接意图识别铁律
|
||||
- list 用于枚举,search 用于标题关键词,get 用于已知稳定 ID。
|
||||
- 空集合是成功;零匹配或多匹配时不得自行选第一条。
|
||||
- 用户明确要求状态、优先级、角色、日期或页码筛选时,使用 `todo task list` 的对应 flag,不要拉全后猜。
|
||||
|
||||
query 未提"听记"但任务产出依赖会议讨论内容时(报告/总结/日报/复盘/商业分析/市场感知),听记采集是**必跑前置步骤**:
|
||||
## 完成、重开与更新
|
||||
|
||||
1. **铁律 A**:任务含"会议/讨论/沟通"信息需求 → `dws minutes list` 必跑
|
||||
2. **铁律 B**:用户说"文档啥也没有" → 听记优先级更高(唯一结构化数据源)
|
||||
3. **铁律 C**:多源聚合场景 → 每个被提及的数据源都必须有采集动作,听记侧 0 调用 = 严重失败
|
||||
```bash
|
||||
dws todo +complete --task-id <TASK_ID> --format json
|
||||
dws todo +reopen --task-id <TASK_ID> --format json
|
||||
dws todo +update --task-id <TASK_ID> --title "<新标题>" --format json
|
||||
```
|
||||
|
||||
只记得标题时用 `+todo-done --task "<关键词>"`;它只在唯一命中时修改。
|
||||
|
||||
## 提醒、汇总与批量
|
||||
|
||||
- `+remind --at` 设置截止时间;独立提醒使用 `+reminder --base-time customTime --at "<提醒ISO>"`。
|
||||
- 截止前提醒使用 `+reminder --base-time dueTime --due-date-offset -30`,待办必须已有截止时间。
|
||||
- 今天/明天/本周汇总:`python scripts/todo_daily_summary.py today|tomorrow|week`。
|
||||
- 逾期扫描:`python scripts/todo_overdue_check.py`。
|
||||
- 批量创建:`python scripts/todo_batch_create.py <todos.json>`;单批最多 30 条,以逐项 ledger 为准。
|
||||
|
||||
@@ -108,7 +108,6 @@ Usage:
|
||||
dws todo task delete [flags]
|
||||
Example:
|
||||
dws todo task delete --task-id <taskId>
|
||||
dws todo task delete --task-id <taskId> --yes
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
```
|
||||
@@ -146,7 +145,6 @@ Usage:
|
||||
dws todo comment delete [flags]
|
||||
Example:
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId>
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes
|
||||
Flags:
|
||||
--task-id string 待办任务 ID (必填)
|
||||
--comment-id string 评论 ID (必填)
|
||||
@@ -237,7 +235,6 @@ Usage:
|
||||
dws todo task remove-attachment [flags]
|
||||
Example:
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId>
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes
|
||||
Flags:
|
||||
--attachment-id string 待办附件 ID (必填)
|
||||
--task-id string 待办任务 ID (必填)
|
||||
@@ -314,7 +311,6 @@ Usage:
|
||||
dws todo tag delete [flags]
|
||||
Example:
|
||||
dws todo tag delete --tag-codes code1,code2
|
||||
dws todo tag delete --tag-codes code1,code2 --yes
|
||||
Flags:
|
||||
--tag-codes string 要删除的标签编码列表,逗号分隔 (必填)
|
||||
--yes 跳过交互确认,直接执行删除
|
||||
@@ -384,7 +380,7 @@ Flags:
|
||||
## 核心工作流
|
||||
|
||||
```bash
|
||||
# 1. 创建待办 — 提取 todoTaskId
|
||||
# 1. 创建待办 — 提取 result.taskId
|
||||
dws todo task create --title "修复线上Bug" --executors userId1,userId2 \
|
||||
--priority 40 --due "2026-03-10T18:00:00+08:00" --format json
|
||||
|
||||
@@ -409,8 +405,8 @@ dws todo task update --task-id <taskId> --title "新标题" --priority 40 --form
|
||||
# 5. 标记待办完成
|
||||
dws todo task done --task-id <taskId> --status true --format json
|
||||
|
||||
# 6. 删除待办
|
||||
dws todo task delete --task-id <taskId> --yes --format json
|
||||
# 6. 删除待办(先按 Runtime gate 取得用户确认)
|
||||
dws todo task delete --task-id <taskId> --format json
|
||||
|
||||
# 7. 给待办新增评论
|
||||
dws todo comment add --task-id <taskId> --content "已开始处理" --format json
|
||||
@@ -418,8 +414,8 @@ dws todo comment add --task-id <taskId> --content "已开始处理" --format jso
|
||||
# 8. 查看待办评论列表
|
||||
dws todo comment list --task-id <taskId> --page 1 --size 20 --format json
|
||||
|
||||
# 9. 删除待办评论
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --yes --format json
|
||||
# 9. 删除待办评论(先按 Runtime gate 取得用户确认)
|
||||
dws todo comment delete --task-id <taskId> --comment-id <commentId> --format json
|
||||
|
||||
# 10. 添加待办执行人
|
||||
dws todo task add-executor --task-id <taskId> --executors userId1,userId2 --format json
|
||||
@@ -445,8 +441,8 @@ dws todo task list-sub --task-id <taskId> --format json
|
||||
dws todo task add-attachment --task-id <taskId> --file /path/to/file.pdf --format json
|
||||
# 20. 查询待办附件列表
|
||||
dws todo task list-attachment --task-id <taskId> --format json
|
||||
# 21. 删除待办附件
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --yes --format json
|
||||
# 21. 删除待办附件(先按 Runtime gate 取得用户确认)
|
||||
dws todo task remove-attachment --task-id <taskId> --attachment-id <attachmentId> --format json
|
||||
|
||||
# 22. 查询待办标签列表
|
||||
dws todo tag list --format json
|
||||
@@ -455,30 +451,29 @@ dws todo tag create --name "标签名" --format json
|
||||
# 24. 给待办打标签
|
||||
dws todo tag add --task-id <taskId> --tag-codes code1,code2 --format json
|
||||
# 25. 更新待办标签
|
||||
dws todo tag update --user-tags '[{"tagCode":"code1","name":"新名称"}]' --format json
|
||||
# 26. 删除待办标签
|
||||
dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
dws todo tag update --user-tags '[{"code":"code1","name":"新名称"}]' --format json
|
||||
# 26. 删除待办标签(先按 Runtime gate 取得用户确认)
|
||||
dws todo tag delete --tag-codes code1,code2 --format json
|
||||
```
|
||||
|
||||
## 上下文传递表
|
||||
|
||||
| 操作 | 从返回中提取 | 用于 |
|
||||
|------|-------------|---------------------------------------------|
|
||||
| `task create` | `todoTaskId` | update/done/get/delete 的 --task-id |
|
||||
| `task list` | `result[].id` | update/done/get/delete 的 --task-id |
|
||||
| `task create` | `todoTaskId` | update/done/get/delete/comment 的 --task-id |
|
||||
| `task list` | `result[].id` | update/done/get/delete/comment/add-executor/remove-executor/add-participant/remove-participant 的 --task-id |
|
||||
| `task create` / `task create-sub` | `result.taskId` | update/done/get/delete/comment 的 `--task-id` |
|
||||
| `task list` | `result.todoCards[].taskId` | update/done/get/delete/comment/member 操作的 `--task-id` |
|
||||
| `task get` | `result.todoDetailModel.subTodos[]` | 获取子待办列表,提取子待办的 `taskId` 用于后续操作 |
|
||||
| `comment list` | `result[].commentId` | `comment delete` 的 --comment-id |
|
||||
| `task list-attachment` | `result[].attachmentId` | `task remove-attachment` 的 --attachment-id |
|
||||
| `comment list` | `result.comments[].id` | `comment delete` 的 `--comment-id` |
|
||||
| `task list-attachment` | `attachments[].attachmentId` | `task remove-attachment` 的 `--attachment-id` |
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 优先级值: 10=低, 20=普通, 30=较高, 40=紧急
|
||||
- `--due` 是截止时间 dueTime,不是提醒时间;使用 ISO-8601 格式(如 2026-03-10T18:00:00+08:00)
|
||||
- 当前不支持单独的 `reminder` / `remind-at` 精确提醒能力;不要把 `--due` 解释成“几点提醒”
|
||||
- `--due` 与 `todo +remind --at` 都是截止时间;不要把它们解释成独立提醒
|
||||
- `--recurrence`:仅在与 `--due` 同时设置时有效;当前仅支持按天循环。字符串内需含换行,示例:`DTSTART:20260320T020000Z\nRRULE:FREQ=DAILY;INTERVAL=1`(DTSTART 表示首次截止时间,需与业务约定一致)
|
||||
- 若用户的真实诉求是“到点提醒我”,需要先说明能力边界;当前 CLI 只能表达 deadline / recurrence,不能表达独立 reminder schedule
|
||||
- 独立提醒使用 `todo +reminder --base-time customTime --at <ISO>`,或原子命令 `task add-reminder --base-time customTime --reminder-time-stamp <ISO>`
|
||||
- 截止前提醒使用 `todo +reminder --base-time dueTime --due-date-offset <分钟>`,或对应原子命令;上游没有提醒查询接口,只能报告写回执
|
||||
- `task list` 的 `--status` 对应 MCP `get_user_todos_in_current_org` 的 `todoStatus` 参数
|
||||
- `task list` 的 `--priority` 支持逗号分隔多个优先级值(如 `40,30,10`),用于同时筛选多个优先级
|
||||
- `task list` 的 `--role-types` 支持 `creator`/`executor`/`participant`,可在一次调用中同时传入多个角色用逗号分隔(如 `--role-types creator,executor`),无需分多次查询;不传时默认按 `executor` 查询
|
||||
@@ -486,7 +481,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- todo 是个人待办管理产品
|
||||
- `task update` 可同时修改标题/优先级/截止时间/完成状态
|
||||
- `task done` 专用于修改执行者的完成状态,与 `task update --done` 作用不同
|
||||
- `task delete` 为不可逆操作,建议加 `--yes` 并与用户确认
|
||||
- `task delete` 为不可逆操作;按 Runtime gate 取得用户确认后执行
|
||||
- `comment delete` 同样为不可逆操作,执行前需用户确认;`--comment-id` 可通过 `comment list` 获取
|
||||
- `task add-executor` / `task remove-executor` 用于管理待办的执行人,`--executors` 支持逗号分隔的多个 userId
|
||||
- `task add-participant` / `task remove-participant` 用于管理待办的参与人,`--participants` 支持逗号分隔的多个 userId
|
||||
@@ -501,7 +496,7 @@ dws todo tag delete --tag-codes code1,code2 --yes --format json
|
||||
- `tag add` 用于给指定待办打标签,`--task-id` 可通过 `task list` 或 `task create` 获取;`--tag-codes` 可通过 `tag list` 获取
|
||||
- `tag create` 用于创建新标签,`--name` 为标签名称 (必填)
|
||||
- `tag update` 用于更新已有标签信息,`--user-tags` 格式同 `tag create`
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作,执行前需用户确认;传 `--yes` 可跳过交互提示,建议加 `--yes` 并与用户确认
|
||||
- `tag delete` 用于删除标签定义,为不可逆操作;按 Runtime gate 取得用户确认后执行
|
||||
- `tag add`(给待办打标签)与 `tag delete`(删除标签定义)作用不同:前者是关联关系,后者是删除标签本身
|
||||
|
||||
|
||||
|
||||
@@ -1303,13 +1303,25 @@ func TestReleaseWorkflowParallelizesSealedValidationWithoutWeakeningPublication(
|
||||
"- e2e",
|
||||
"verify-github-tag-authority.sh",
|
||||
"go test -v -count=1 -timeout=5m ./test/scripts/... -run '^TestRelease'",
|
||||
"check-command-compatibility.sh",
|
||||
`tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh`,
|
||||
`--repo-root "$GITHUB_WORKSPACE"`,
|
||||
`--base-ref HEAD`,
|
||||
`--stable-ref "$PREVIOUS_STABLE"`,
|
||||
`--candidate-ref HEAD`,
|
||||
"test-multi-profile-e2e.sh",
|
||||
} {
|
||||
if !strings.Contains(validation, required) {
|
||||
t.Errorf("parallel release validation is missing %q", required)
|
||||
}
|
||||
}
|
||||
for _, forbidden := range []string{
|
||||
"./scripts/policy/check-command-compatibility.sh",
|
||||
"./scripts/policy/check-authoritative-schema-compatibility.sh",
|
||||
} {
|
||||
if strings.Contains(validation, forbidden) {
|
||||
t.Errorf("parallel release validation bypasses the shared compatibility runner with %q", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(build, "test-multi-profile-e2e.sh") {
|
||||
t.Error("multi-profile E2E must not serialize GoReleaser")
|
||||
@@ -1379,7 +1391,7 @@ func TestReleaseWorkflowHidesOnlyVerifiedSealedTagFromCompatibilityBaseline(t *t
|
||||
|
||||
verifiedTag := strings.Index(validation, "verify-github-tag-authority.sh")
|
||||
deleteLocalTag := strings.Index(validation, "git update-ref -d")
|
||||
compatibility := strings.Index(validation, "check-command-compatibility.sh")
|
||||
compatibility := strings.Index(validation, "tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh")
|
||||
if verifiedTag == -1 || deleteLocalTag == -1 || compatibility == -1 ||
|
||||
verifiedTag > deleteLocalTag || deleteLocalTag > compatibility {
|
||||
t.Fatal("release tag authority verification, local candidate removal, and compatibility checking must stay ordered")
|
||||
|
||||
@@ -75,6 +75,7 @@ type releaseTestRepo struct {
|
||||
prepare string
|
||||
render string
|
||||
releaseCmd string
|
||||
compat string
|
||||
lib string
|
||||
verify string
|
||||
}
|
||||
@@ -110,11 +111,206 @@ func newReleaseTestRepo(t *testing.T) *releaseTestRepo {
|
||||
prepare: filepath.Join(sourceRoot, "scripts", "release", "prepare-changelog.sh"),
|
||||
render: filepath.Join(sourceRoot, "scripts", "release", "render-release-fragments.sh"),
|
||||
releaseCmd: filepath.Join(sourceRoot, "scripts", "release", "release.sh"),
|
||||
compat: filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh"),
|
||||
lib: filepath.Join(sourceRoot, "scripts", "release", "release-lib.sh"),
|
||||
verify: filepath.Join(sourceRoot, "scripts", "release", "verify-release-artifacts.sh"),
|
||||
}
|
||||
}
|
||||
|
||||
func seedReleaseCompatibilityRefs(t *testing.T, repo string) (stable, base, candidate string) {
|
||||
t.Helper()
|
||||
mustRun(t, repo, "git", "config", "user.name", "Release Compatibility Test")
|
||||
mustRun(t, repo, "git", "config", "user.email", "release-compatibility-test@example.com")
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("stable\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "stable")
|
||||
mustRun(t, repo, "git", "tag", "stable-ref")
|
||||
stable = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("base\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "base")
|
||||
mustRun(t, repo, "git", "branch", "base-ref")
|
||||
base = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
|
||||
mustWriteFile(t, filepath.Join(repo, "seed.txt"), []byte("candidate\n"), 0o644)
|
||||
mustRun(t, repo, "git", "add", "seed.txt")
|
||||
mustRun(t, repo, "git", "commit", "-m", "candidate")
|
||||
mustRun(t, repo, "git", "branch", "candidate-ref")
|
||||
candidate = strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "HEAD^{commit}"))
|
||||
return stable, base, candidate
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckRunsCLIAndSchemaWithExactRefs(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
checker := func(name string) []byte {
|
||||
return []byte("#!/bin/sh\nset -eu\nprintf '" + name + ":%s\\n' \"$*\" >> \"$TRACE\"\n")
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), checker("cli"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), checker("schema"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate) +
|
||||
fmt.Sprintf("schema:--base-ref %s --stable-ref %s --candidate-ref %s\n", base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility calls = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFreezesRefsBeforeRunningEitherChecker(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
stable, base, candidate := seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
resolveAndLog := `
|
||||
base="$2"
|
||||
stable="$4"
|
||||
candidate="$6"
|
||||
printf '%s:%s:%s:%s\n' "$CHECK_NAME" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${base}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${stable}^{commit}")" \
|
||||
"$(git -C "$REPO_ROOT_FOR_TEST" rev-parse --verify "${candidate}^{commit}")" >> "$TRACE"
|
||||
`
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=cli\n"+resolveAndLog+"git -C \"$REPO_ROOT_FOR_TEST\" update-ref refs/heads/base-ref \"$MUTATE_TO\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nCHECK_NAME=schema\n"+resolveAndLog), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace, "REPO_ROOT_FOR_TEST="+repo, "MUTATE_TO="+candidate)
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("release compatibility check error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
want := fmt.Sprintf("cli:%s:%s:%s\nschema:%s:%s:%s\n", base, stable, candidate, base, stable, candidate)
|
||||
if string(got) != want {
|
||||
t.Fatalf("release compatibility resolved commits = %q, want %q", got, want)
|
||||
}
|
||||
if moved := strings.TrimSpace(mustOutput(t, repo, "git", "rev-parse", "base-ref^{commit}")); moved != candidate {
|
||||
t.Fatalf("mutating checker did not move base-ref: got %s, want %s", moved, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckStopsBeforeSchemaWhenCLIFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\nexit 17\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 17 {
|
||||
t.Fatalf("release compatibility CLI failure = %v, want exit 17\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative CLI compatibility failed") {
|
||||
t.Fatalf("release compatibility CLI failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\n" {
|
||||
t.Fatalf("checks after CLI failure = %q, want only CLI", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCompatibilityCheckFailsWhenSchemaFails(t *testing.T) {
|
||||
t.Parallel()
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatalf("Abs(repo root) error = %v", err)
|
||||
}
|
||||
runner := filepath.Join(sourceRoot, "scripts", "release", "check-release-compatibility.sh")
|
||||
repo := t.TempDir()
|
||||
mustRun(t, repo, "git", "init", "-b", "main")
|
||||
seedReleaseCompatibilityRefs(t, repo)
|
||||
|
||||
trace := filepath.Join(t.TempDir(), "compatibility.log")
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nprintf 'cli\\n' >> \"$TRACE\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(repo, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nprintf 'schema\\n' >> \"$TRACE\"\nexit 19\n"), 0o755)
|
||||
|
||||
cmd := exec.Command(
|
||||
runner,
|
||||
"--repo-root", repo,
|
||||
"--base-ref", "base-ref",
|
||||
"--stable-ref", "stable-ref",
|
||||
"--candidate-ref", "candidate-ref",
|
||||
)
|
||||
cmd.Env = append(os.Environ(), "TRACE="+trace)
|
||||
output, err := cmd.CombinedOutput()
|
||||
exitErr, ok := err.(*exec.ExitError)
|
||||
if !ok || exitErr.ExitCode() != 19 {
|
||||
t.Fatalf("release compatibility Schema failure = %v, want exit 19\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(string(output), "error: authoritative Schema compatibility failed") {
|
||||
t.Fatalf("release compatibility Schema failure has no boundary message:\n%s", output)
|
||||
}
|
||||
got, err := os.ReadFile(trace)
|
||||
if err != nil {
|
||||
t.Fatalf("ReadFile(%s) error = %v", trace, err)
|
||||
}
|
||||
if string(got) != "cli\nschema\n" {
|
||||
t.Fatalf("checks before Schema failure = %q, want CLI then Schema", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseVersionOrdering(t *testing.T) {
|
||||
sourceRoot, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
@@ -2631,12 +2827,35 @@ func TestReleaseCommandRejectsDifferentFetchAndPushRepositories(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRunsCLIAndSchemaCompatibilityInInitialPreflight(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(betaSection())), 0o644)
|
||||
r.commitAndPush(t, "install release compatibility fixture")
|
||||
|
||||
output, err := runReleaseScript(t, r.root, filepath.Join(r.root, "scripts", "release", "release.sh"),
|
||||
"prerelease", "v1.0.1-beta.1", "--remote", "origin",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.0^{commit}"))
|
||||
for _, want := range []string{
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("initial release preflight is missing %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
r := newReleaseTestRepo(t)
|
||||
installReleaseCommandFixture(t, r)
|
||||
section := "## [1.0.2-beta.1] - 2026-07-11\n\n### Changed\n\n- Validate a candidate after stable authority advances.\n\n"
|
||||
mustWriteFile(t, filepath.Join(r.root, "CHANGELOG.md"), []byte(releaseChangelog(section)), 0o644)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@git tag -a v1.0.1 -m 'Release v1.0.1'\n\t@git push origin refs/tags/v1.0.1\n"), 0o644)
|
||||
r.commitAndPush(t, "install advancing release fixture")
|
||||
|
||||
@@ -2646,20 +2865,28 @@ func TestReleaseCommandRechecksAdvancedStableAuthority(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("release validation error = %v\noutput:\n%s", err, output)
|
||||
}
|
||||
if !strings.Contains(output, "Stable authority advanced from v1.0.0 to v1.0.1") ||
|
||||
!strings.Contains(output, "--stable-ref v1.0.1") {
|
||||
t.Fatalf("advanced stable command tree was not rechecked:\n%s", output)
|
||||
head := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "HEAD^{commit}"))
|
||||
stable := strings.TrimSpace(mustOutput(t, r.root, "git", "rev-parse", "v1.0.1^{commit}"))
|
||||
for _, want := range []string{
|
||||
"Stable authority advanced from v1.0.0 to v1.0.1",
|
||||
fmt.Sprintf("cli-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
fmt.Sprintf("schema-compatibility --base-ref %s --stable-ref %s --candidate-ref %s", head, stable, head),
|
||||
} {
|
||||
if !strings.Contains(output, want) {
|
||||
t.Errorf("advanced stable authority recheck is missing %q\noutput:\n%s", want, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func installReleaseCommandFixture(t *testing.T, r *releaseTestRepo) {
|
||||
t.Helper()
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd} {
|
||||
for _, source := range []string{r.lib, r.contract, r.releaseCmd, r.compat} {
|
||||
releaseCopyFile(t, source, filepath.Join(r.root, "scripts", "release", filepath.Base(source)), 0o755)
|
||||
}
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-package-managers.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "release", "verify-release-artifacts.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-command-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nexit 0\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-interface-baselines.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'cli-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "scripts", "policy", "check-authoritative-schema-compatibility.sh"), []byte("#!/bin/sh\nset -eu\nprintf 'schema-compatibility %s\\n' \"$*\"\n"), 0o755)
|
||||
mustWriteFile(t, filepath.Join(r.root, "Makefile"), []byte("test:\n\t@:\nbuild:\n\t@:\npolicy:\n\t@:\npackage:\n\t@:\n"), 0o644)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user