diff --git a/.changes/README.md b/.changes/README.md index 79b5868f..1ed1fbd3 100644 --- a/.changes/README.md +++ b/.changes/README.md @@ -25,7 +25,9 @@ category: Added 发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序, 将未归档 fragments 汇总为唯一的版本章节,并移动到 -`.changes/released//`。因此 release-seal PR 是唯一会修改 +`.changes/released//`。beta 发布后若有新 fragments 合入并直接准备 stable, +stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新 +fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改 `CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。 归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、 删除或重写已归档文件。 diff --git a/.changes/oa-approval-attachment-upload.md b/.changes/oa-approval-attachment-upload.md new file mode 100644 index 00000000..6d4089c7 --- /dev/null +++ b/.changes/oa-approval-attachment-upload.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **OA approval attachment upload** — `dws oa approval attachment upload --file ` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted. diff --git a/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md new file mode 100644 index 00000000..e6b650d2 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1045-attendance-mail-shortcuts.md @@ -0,0 +1,14 @@ +--- +category: Changed +--- + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. diff --git a/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md new file mode 100644 index 00000000..b84f9ebc --- /dev/null +++ b/.changes/released/1.0.59-beta.4/1058-chat-group-role-single-flag.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. diff --git a/.changes/released/1.0.59-beta.4/976-recruit-job.md b/.changes/released/1.0.59-beta.4/976-recruit-job.md new file mode 100644 index 00000000..a1be13c3 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/976-recruit-job.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 diff --git a/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md new file mode 100644 index 00000000..c206decc --- /dev/null +++ b/.changes/released/1.0.59-beta.4/996-canonical-agent-skills.md @@ -0,0 +1,5 @@ +--- +category: Fixed +--- + +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. diff --git a/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md new file mode 100644 index 00000000..166f6708 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/chat-user-mention-tokens.md @@ -0,0 +1,6 @@ +--- +category: Fixed +--- + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. diff --git a/.changes/released/1.0.59-beta.4/command-path-migration-governance.md b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md new file mode 100644 index 00000000..61f89635 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/command-path-migration-governance.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. diff --git a/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md new file mode 100644 index 00000000..da6f2c33 --- /dev/null +++ b/.changes/released/1.0.59-beta.4/oa-approval-list-by-admin.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. diff --git a/.changes/released/1.0.59-beta.5/1050-shortcut-functional-hardening.md b/.changes/released/1.0.59-beta.5/1050-shortcut-functional-hardening.md new file mode 100644 index 00000000..1d28f673 --- /dev/null +++ b/.changes/released/1.0.59-beta.5/1050-shortcut-functional-hardening.md @@ -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. diff --git a/.changes/released/1.0.59-beta.5/chat-personal-emotion.md b/.changes/released/1.0.59-beta.5/chat-personal-emotion.md new file mode 100644 index 00000000..ed7e9e04 --- /dev/null +++ b/.changes/released/1.0.59-beta.5/chat-personal-emotion.md @@ -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. diff --git a/.changes/released/1.0.59-beta.5/minutes-todo-wiki-param-aliases.md b/.changes/released/1.0.59-beta.5/minutes-todo-wiki-param-aliases.md new file mode 100644 index 00000000..e7c4fb2d --- /dev/null +++ b/.changes/released/1.0.59-beta.5/minutes-todo-wiki-param-aliases.md @@ -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. diff --git a/.changes/released/1.0.60-beta.1/1074-shortcut-regression-fixes.md b/.changes/released/1.0.60-beta.1/1074-shortcut-regression-fixes.md new file mode 100644 index 00000000..55390ec8 --- /dev/null +++ b/.changes/released/1.0.60-beta.1/1074-shortcut-regression-fixes.md @@ -0,0 +1,7 @@ +--- +category: Fixed +--- + +- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel. +- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures. +- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC. diff --git a/.changes/released/1.0.60-beta.1/oa-ding-report-shortcuts.md b/.changes/released/1.0.60-beta.1/oa-ding-report-shortcuts.md new file mode 100644 index 00000000..c24c62e4 --- /dev/null +++ b/.changes/released/1.0.60-beta.1/oa-ding-report-shortcuts.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. diff --git a/.changes/released/1.0.60-beta.1/refresh-org-slot-fallback.md b/.changes/released/1.0.60-beta.1/refresh-org-slot-fallback.md new file mode 100644 index 00000000..bb484a97 --- /dev/null +++ b/.changes/released/1.0.60-beta.1/refresh-org-slot-fallback.md @@ -0,0 +1,11 @@ +--- +category: Fixed +--- + +- **OAuth refresh falls back to the organization mirror** — when the server rejects the + current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound` + business code, `dws` now retries once with the still-valid token mirrored in the same + organization's slot (same corp, matching or backfilled user identity) before giving up, + and writes the rotated credential back to both the identity and the organization slots so + the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP + rejections without a reviewed business code do not trigger the fallback. diff --git a/.changes/released/1.0.60-beta.1/stable-active-fragment-seal.md b/.changes/released/1.0.60-beta.1/stable-active-fragment-seal.md new file mode 100644 index 00000000..72958e9d --- /dev/null +++ b/.changes/released/1.0.60-beta.1/stable-active-fragment-seal.md @@ -0,0 +1,5 @@ +--- +category: Changed +--- + +- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes. diff --git a/.changes/sheet-revision-changesets.md b/.changes/sheet-revision-changesets.md new file mode 100644 index 00000000..e6978c30 --- /dev/null +++ b/.changes/sheet-revision-changesets.md @@ -0,0 +1,5 @@ +--- +category: Added +--- + +- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dc437165..49a76f09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -512,6 +512,12 @@ jobs: if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' run: node .github/reviewer-routing.test.js + - name: Test npm installer smoke (prune, backup, publish) + if: steps.classify.outputs.changelog_only != 'true' && steps.classify.outputs.docs_only != 'true' + env: + XDG_CONFIG_HOME: "" + run: node test/scripts/install_js_smoke.mjs + test-focused: name: "Test (focused: ${{ matrix.shard }})" needs: lint @@ -528,7 +534,8 @@ jobs: # where the Schema partition alone owned most of the wall clock. The # app- names are pinned to the helper's partition set by # TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose - # its job silently. + # its job silently. The CrossPlatformCoverage-heavy C range is split again + # to retain headroom on runners reclaimed near the five-minute mark. timeout-minutes: 20 strategy: fail-fast: false @@ -536,7 +543,10 @@ jobs: shard: - app-schema - app-a-b - - app-c + - app-c-a-l + - app-c-m-r + - app-c-s-z + - app-c-other - app-d-r - app-s-z-example-fuzz - generators @@ -611,7 +621,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' }} @@ -666,7 +682,8 @@ jobs: # partitions run concurrently and each releases its framework registries # when the process exits; cli/smoke need headroom beyond go test -timeout for # setup + assembly. The app- names are pinned to the helper's - # partition set by TestCIAppRacePartitionMatrixMatchesHelper. + # partition set by TestCIAppRacePartitionMatrixMatchesHelper. The + # CrossPlatformCoverage-heavy C range is split again for runner headroom. timeout-minutes: 20 strategy: fail-fast: false @@ -674,7 +691,10 @@ jobs: shard: - app-schema - app-a-b - - app-c + - app-c-a-l + - app-c-m-r + - app-c-s-z + - app-c-other - app-d-r - app-s-z-example-fuzz - generators @@ -751,7 +771,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 @@ -1129,7 +1155,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 @@ -1180,7 +1212,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: | @@ -1261,7 +1299,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' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8ee47b14..774495a5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 728d8f7b..16dddbec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,87 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th ## [Unreleased] +## [1.0.60-beta.1] - 2026-08-21 + +### Changed + +- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence. + +- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes. + +### Fixed + +- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel. +- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures. +- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC. + +- **OAuth refresh falls back to the organization mirror** — when the server rejects the + current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound` + business code, `dws` now retries once with the still-valid token mirrored in the same + organization's slot (same corp, matching or backfilled user identity) before giving up, + and writes the rotated credential back to both the identity and the organization slots so + the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP + rejections without a reviewed business code do not trigger the fallback. + + +## [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 + +- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。 + +- **OA admin approval query** — `oa approval list-by-admin` queries approval instances of a template with admin scope, with simple flags and an advanced `--request` mode; `startTime`/`endTime` use `yyyy-MM-dd HH:mm:ss` strings per the 2026-08 MCP contract update (ISO-8601 flag inputs auto-convert), and pageSize/time format are validated client-side with localized errors. + +### Changed + +- **Attendance and Mail Shortcuts** (#1045) — publishes only capabilities with + strict response, identity, pagination, and real-data verification while + retaining historical CLI discovery and argument compatibility for commands + that remain unavailable to agents. Mailbox auto-resolution now accepts both + reviewed string and object response shapes, and Attendance date ranges cover + the complete requested end date without dropping cross-midnight punches whose + actual check time is inside the requested range. The schedule query remains + CLI-compatible but is withheld from the Agent catalog because its downstream + service returns a successful process exit with a null body for both populated + and empty ranges. + +- **Chat group roles** (#1058) — exposes the single-value `--role-id` flag for assigning one custom group role while preserving hidden `--role-ids` compatibility. + +- **CLI compatibility governance** — adds a reviewed two-stage path for hiding retained legacy commands or optional `NoOpt=true` boolean flags from Help and Schema when their activated capability moves to a dedicated command, with legacy-leaf, complete parameter/constant mapping, durable runtime constant evidence, protected framework bridges, dry-run preservation, parameter-collision, and fail-closed required-parameter checks. + +### Fixed + +- **Canonical Agent Skills** (#996) — installs bundled DWS Skills once under `~/.agents/skills`, migrates duplicate Agent copies, and matches the upstream 76-Agent registry across Go, npm, Shell, and PowerShell. Non-universal Agents use directory links — junctions on Windows from the npm and PowerShell installers, symbolic links from `dws upgrade` / `dws skill setup` — with a safe copy fallback when link creation is unavailable (including Windows without Developer Mode); custom/XDG homes and OpenClaw legacy aliases are preserved. Upgrades now back up and restore Skills safely across external volumes by staging, lexically copying links (including dangling links), verifying contents, and deleting the source only after publication succeeds. Atomic no-replace publication and identity-checked quarantine rollback preserve concurrent user changes instead of overwriting or recursively deleting them; filesystems that reject the no-replace flag (NFS, FUSE, overlayfs) keep the no-clobber contract by holding the claimed destination for the entire transaction — renaming over the claim where the platform permits it, otherwise moving the source children into it — instead of unlinking the claim and retrying a plain rename, which opened a window where a concurrent directory could be overwritten. A degraded child-move that cannot consume the emptied source shell fails the publication loudly with the destination retained, so a failed move never silently discards staging data. When the fresh mkdir-claim identity captured by the child-move fallback — dev:ino on POSIX, the volume file ID on Windows — no longer matches the destination, the publication reports `ErrSkillPathPublicationUncertain` and keeps the destination: the object may belong to a concurrent writer, and the mono/multi upgrade copy fallbacks as well as `dws skill setup` honor the sentinel by surfacing the state instead of retrying over (and displacing) it. npm same-volume and cross-volume moves, PowerShell recoverably moves, and npm canonical-link / copied-set confirmation follow the same occupy-then-confirm contract. Same-volume publication and restore use a no-replace primitive (mkdir-claim plus child move, symlink-at-dest, or hard-link plus retract) so a dest that becomes occupied after any pre-check is refused instead of replaced. A rollback that has already quarantined dest re-checks identity in quarantine and restores an unmatched object onto dest with that same no-replace publish, so dest is not left empty with the concurrent object hidden unless the restore itself fails (then both locations are named). The npm installer now proves ownership at dest (inode, device, fingerprint) before any quarantine rename, matching Go: a concurrent replacement is left on the original path, and only a post-quarantine identity change is restored with no-replace. Go identity proofs report a stable file identity on every platform — dev:ino on Linux and macOS, the volume file ID on Windows — with the post-publish content fingerprint as the backstop against inode recycling; Shell copied-set rollback proves dest first with inode, child names, and a recursive content digest (sorted paths, mode bits, file hashes, link targets) for the same reason, so an in-place content edit after publish is preserved rather than retracted as this transaction's object. The npm installer's mono and multi set copy publication claims the destination with an atomic mkdir before moving the staged children into it, and canonical link publication creates the symlink or junction directly at the destination, so a concurrently created file, symlink, or directory is refused atomically instead of being replaced by Node's replacing rename or linked into by `ln -P` on POSIX or Windows. The standalone event/devapp copy publishers use the same mkdir claim instead of `mv`ing a staging directory onto dest. Shell copied-set and link rollback claim dest into quarantine before deleting, so a concurrent writer's file, symlink, or directory is renamed back instead of being deleted by a path-blind `rm` after an inode pre-check. Because that degraded publication has no atomic no-clobber primitive for a link, `dws skill setup` falls back to a direct copy when a link fails to *publish* as well as when it fails to stage, so non-universal Agents are still configured on those filesystems — except when the failure reports the uncertain sentinel, which retains the destination for manual inspection instead of retrying over it. Failing to retire an obsolete Agent copy is now a warning rather than an install failure. Every install surface prunes `~/.dws/skill-backups` to the newest 5 batches from earlier runs while never deleting a backup taken by the run in progress, so a migration that retires more than 5 batches stays reversible; stamp roots created before the ownership marker existed are preserved rather than pruned. Standalone installers verify every downloaded release asset against `checksums.txt`, and the npm engine declaration now reflects the actual Node 16.7+ API floor. + +- **Chat user mentions** — preserves literal `<@openDingTalkId>` tokens in current-user Markdown messages and rejects mismatches between message-body mentions and mention flags before sending. +- **Chat direct media** — uses the IM upload target field for current-user direct file, audio, and video uploads, then uses the Chat receiver field for final message delivery. + + ## [1.0.59-beta.3] - 2026-08-19 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ce9d6372..4c334984 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code. `make authoritative-interface-integrity BASE_REF= STABLE_REF= CANDIDATE_REF=`. The Make target delegates to the authoritative wrapper; CI does not invoke a second comparator or the legacy fixture checker. See - [CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md) + [CLI Help / Schema compatibility migration governance](docs/cli-interface-flag-migrations.md) for the reviewed two-stage `pending` → `consumed` lifecycle. - Agent-visible flag migrations must also run + Agent-visible flag or command-path migrations must also run `make schema-compatibility BASE_REF= STABLE_REF= CANDIDATE_REF=`; it consumes the same base-owned ledger rather than a second exception list. 5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may diff --git a/Formula/dingtalk-workspace-cli-beta.rb b/Formula/dingtalk-workspace-cli-beta.rb index 90ab9635..28413205 100644 --- a/Formula/dingtalk-workspace-cli-beta.rb +++ b/Formula/dingtalk-workspace-cli-beta.rb @@ -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.60-beta.1" 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.60-beta.1/dws-darwin-arm64.tar.gz" + sha256 "8ef11c79b5c86ec275dd82334232e7582f9e2ba99a66307d7681e42e8f53767b" 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.60-beta.1/dws-darwin-amd64.tar.gz" + sha256 "67612f1dac735984b026c7f8a0dc057beec4cdd029f0a97798bf90aa923eb2d3" 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.60-beta.1/dws-linux-arm64.tar.gz" + sha256 "67a8d4f4e0a7d22a9cc53cb91d8c97ecd1152665ce669f68560d86cec5987dd2" 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.60-beta.1/dws-linux-amd64.tar.gz" + sha256 "a5fae548b495842779df4291cbcf06d8a2e5ddddf68a41cad1bab1e5c64a1d59" 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.60-beta.1/dws-skills.zip" + sha256 "9fe12683139a626d32a801dd44158a698f142b61339282e0fc24d4e3a5e97e87" end def install diff --git a/Formula/dingtalk-workspace-cli.rb b/Formula/dingtalk-workspace-cli.rb index 46d064f7..5662ae17 100644 --- a/Formula/dingtalk-workspace-cli.rb +++ b/Formula/dingtalk-workspace-cli.rb @@ -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 diff --git a/README.md b/README.md index ec3e7416..58775c3f 100644 --- a/README.md +++ b/README.md @@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur The upgrade process follows a two-phase atomic flow to ensure consistency: 1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation. -2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice. +2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice. A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed. @@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh ``` -> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory. +> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory. > > China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror). @@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
Personal Event Subscription — real-time DingTalk messages for event-driven agents -`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events. +`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events. The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`. @@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group --flatten dws event +listen-im --kind sender --user \ --events message,read,recall -f ndjson -# Listen for all six public OA approval events in one process +# Listen for all seven public OA approval events in one process dws event consume \ user_oa_approval_task_created \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten -f ndjson diff --git a/README_zh.md b/README_zh.md index e47544a4..29e55abc 100644 --- a/README_zh.md +++ b/README_zh.md @@ -19,7 +19,7 @@

> [!IMPORTANT] -> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。 +> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。 > > dws 开源沟通群二维码 @@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
个人事件订阅 — 实时接收钉钉消息,驱动事件触发的 Agent -`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。 +`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。 默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。 @@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group --flatten dws event +listen-im --kind sender --user \ --events message,read,recall -f ndjson -# 一个进程监听全部六个公开 OA 审批事件 +# 一个进程监听全部七个公开 OA 审批事件 dws event consume \ user_oa_approval_task_created \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten -f ndjson diff --git a/build/npm/install.js b/build/npm/install.js index 49e9fd7f..58e2a7f1 100755 --- a/build/npm/install.js +++ b/build/npm/install.js @@ -8,27 +8,122 @@ const os = require("os"); const path = require("path"); const childProcess = require("child_process"); -// Canonical list: keep scripts/install.sh, scripts/install.ps1, scripts/install-skills.sh in sync. -const AGENT_DIRS = [ - ".agents/skills", - ".claude/skills", - ".cursor/skills", - ".qoder/skills", - ".qoderwork/skills", - ".gemini/skills", - ".codex/skills", - ".zcode/skills", - ".github/skills", - ".windsurf/skills", - ".augment/skills", - ".cline/skills", - ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +// Exact vercel-labs/skills registry at the reference revision: 76 Agent IDs, +// including canonical-direct and no-global-directory entries. The resolver +// below intentionally deduplicates shared effective roots only after this +// authoritative enumeration has been classified. +const UPSTREAM_AGENTS = [ + ["aider-desk", false, ".aider-desk/skills"], + ["amp", true, ".config/agents/skills"], + ["antigravity", true, ".gemini/antigravity/skills"], + ["antigravity-cli", true, ".gemini/antigravity-cli/skills"], + ["astrbot", false, ".astrbot/data/skills"], + ["autohand-code", false, ".autohand/skills"], + ["augment", false, ".augment/skills"], ["bob", false, ".bob/skills"], + ["claude-code", false, ".claude/skills"], ["openclaw", false, ".openclaw/skills"], + ["cline", true, ".agents/skills"], ["codearts-agent", false, ".codeartsdoer/skills"], + ["codebuddy", false, ".codebuddy/skills"], ["codemaker", false, ".codemaker/skills"], + ["codestudio", false, ".codestudio/skills"], ["codex", true, ".codex/skills"], + ["command-code", false, ".commandcode/skills"], ["continue", false, ".continue/skills"], + ["cortex", false, ".snowflake/cortex/skills"], ["crush", false, ".config/crush/skills"], + ["cursor", true, ".cursor/skills"], ["deepagents", true, ".deepagents/agent/skills"], + ["devin", false, ".config/devin/skills"], ["dexto", true, ".agents/skills"], + ["droid", false, ".factory/skills"], ["eve", false, null], + ["firebender", true, ".firebender/skills"], ["forgecode", false, ".forge/skills"], + ["gemini-cli", true, ".gemini/skills"], ["github-copilot", true, ".copilot/skills"], + ["goose", false, ".config/goose/skills"], ["grok", false, ".grok/skills"], + ["hermes-agent", false, ".hermes/skills"], ["inference-sh", false, ".inferencesh/skills"], + ["jazz", false, ".jazz/skills"], ["junie", false, ".junie/skills"], + ["iflow-cli", false, ".iflow/skills"], ["kilo", false, ".kilocode/skills"], + ["kimchi", false, ".config/kimchi/harness/skills"], ["kimi-code-cli", true, ".agents/skills"], + ["kiro-cli", false, ".kiro/skills"], ["kode", false, ".kode/skills"], + ["lingma", false, ".lingma/skills"], ["loaf", true, ".agents/skills"], + ["mcpjam", false, ".mcpjam/skills"], ["minimax-code", false, ".minimax/skills"], + ["mistral-vibe", false, ".vibe/skills"], ["moxby", false, ".moxby/skills"], + ["mux", false, ".mux/skills"], ["opencode", true, ".config/opencode/skills"], + ["openhands", false, ".openhands/skills"], ["ona", false, ".ona/skills"], + ["pi", false, ".pi/agent/skills"], ["qoder", false, ".qoder/skills"], + ["qoder-cn", false, ".qoder-cn/skills"], ["qwen-code", false, ".qwen/skills"], + ["replit", true, ".config/agents/skills"], ["reasonix", false, ".reasonix/skills"], + ["rovodev", false, ".rovodev/skills"], ["roo", false, ".roo/skills"], + ["tabnine-cli", false, ".tabnine/agent/skills"], ["terramind", false, ".terramind/skills"], + ["tinycloud", false, ".tinycloud/skills"], ["trae", false, ".trae/skills"], + ["trae-cn", false, ".trae-cn/skills"], ["warp", true, ".agents/skills"], + ["windsurf", false, ".codeium/windsurf/skills"], ["zed", true, ".agents/skills"], + ["zcode", false, ".zcode/skills"], ["zencoder", false, ".zencoder/skills"], + ["zenflow", false, ".zencoder/skills"], ["neovate", false, ".neovate/skills"], + ["pochi", false, ".pochi/skills"], ["promptscript", true, null], + ["adal", false, ".adal/skills"], ["universal", true, ".config/agents/skills"], +].map(([id, universal, agentDir]) => ({ id, universal, agentDir })); + +// Paths emitted by beta.6 or older DWS installers that do not correspond to +// the upstream registry. They are cleanup-only migration targets. +const LEGACY_UNIVERSAL_AGENT_DIRS = [ + ".github/skills", ".windsurf/skills", ".cline/skills", ".amp/skills", ]; +function openClawSkillsDir(homeDir) { + for (const name of [".openclaw", ".clawdbot", ".moltbot"]) { + const root = path.join(homeDir, name); + if (fs.existsSync(root)) return path.join(root, "skills"); + } + return path.join(homeDir, ".openclaw", "skills"); +} + +function resolvedAgentTargets(homeDir) { + const xdgConfigHome = (process.env.XDG_CONFIG_HOME || "").trim() || path.join(homeDir, ".config"); + const targets = [ + ...UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir && agentDir !== ".agents/skills"), + // DWS compatibility target; qoderwork is not in upstream's registry. + { id: "dws-qoderwork", agentDir: ".qoderwork/skills", universal: false, extension: true }, + ...LEGACY_UNIVERSAL_AGENT_DIRS.map((agentDir) => ({ agentDir, universal: true, legacy: true })), + ].map((target) => { + const { agentDir } = target; + let baseDir = path.join(homeDir, agentDir); + if (agentDir === ".claude/skills" && (process.env.CLAUDE_CONFIG_DIR || "").trim()) { + baseDir = path.join(process.env.CLAUDE_CONFIG_DIR.trim(), "skills"); + } else if (agentDir === ".codex/skills" && (process.env.CODEX_HOME || "").trim()) { + baseDir = path.join(process.env.CODEX_HOME.trim(), "skills"); + } else if (agentDir === ".hermes/skills" && (process.env.HERMES_HOME || "").trim()) { + baseDir = path.join(process.env.HERMES_HOME.trim(), "skills"); + } else if (agentDir === ".autohand/skills" && (process.env.AUTOHAND_HOME || "").trim()) { + baseDir = path.join(process.env.AUTOHAND_HOME.trim(), "skills"); + } else if (agentDir === ".grok/skills" && (process.env.GROK_HOME || "").trim()) { + baseDir = path.join(process.env.GROK_HOME.trim(), "skills"); + } else if (agentDir === ".vibe/skills" && (process.env.VIBE_HOME || "").trim()) { + baseDir = path.join(process.env.VIBE_HOME.trim(), "skills"); + } else if (agentDir === ".openclaw/skills") { + baseDir = openClawSkillsDir(homeDir); + } else if (agentDir.startsWith(".config/")) { + baseDir = path.join(xdgConfigHome, agentDir.slice(".config/".length)); + } + return { ...target, baseDir }; + }); + const seen = new Set(); + return targets.filter((target) => { + let key = path.resolve(target.baseDir); + if (process.platform === "win32") key = key.toLowerCase(); + if (seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function agentTargetDetected(target) { + const parent = path.dirname(target.baseDir); + switch (target.id) { + case "kimchi": + case "tabnine-cli": + return fs.existsSync(path.dirname(parent)); + case "zcode": + return fs.existsSync(parent) || fs.existsSync("/Applications/ZCode.app"); + case "minimax-code": + return fs.existsSync(parent) || fs.existsSync("/Applications/MiniMax Code.app"); + default: + return fs.existsSync(parent); + } +} + const PLATFORM_MAP = { "darwin-x64": "dws-darwin-amd64.tar.gz", "darwin-arm64": "dws-darwin-arm64.tar.gz", @@ -58,45 +153,656 @@ function backupStamp() { ); } +// SKILL_BACKUP_KEEP bounds ~/.dws/skill-backups/ growth: only the newest +// stamped backup directories are kept. Mirrors skillBackupKeep and +// pruneSkillBackups in internal/upgrade/paths.go so every installer surface +// applies the same retention. +const SKILL_BACKUP_KEEP = 5; + +// Backup roots this process created are never pruned: an in-flight +// transaction still needs them to roll back. +const currentRunBackupRoots = new Set(); + +// Ownership marker: every stamp root DWS creates carries .dws-skill-backup +// with exactly these bytes — the same bytes internal/upgrade/paths.go, +// install.sh, and the PowerShell installers write. A stamp-shaped name alone +// is not ownership proof, so pruning only deletes directories whose marker +// content matches byte-for-byte (cross-surface exact-LF contract; a +// PowerShell writer's hypothetical CRLF is normalized away on that surface, +// while every writer here emits LF). +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +// writeSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +function writeSkillBackupMarker(root) { + fs.writeFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + +// skillBackupRootUsable reports whether a stamp root may be written into: +// either it does not exist yet (this transaction creates it fresh) or its +// ownership marker already verifies. A stamp-shaped directory created by +// anyone else must never be adopted — writing our marker into it would turn +// its foreign contents into prunable DWS backups. +function skillBackupRootUsable(root) { + if (!pathExistsLexicallySync(root)) return true; + // A root this very process created is ours by construction and stays + // reusable even when its marker cannot be re-verified mid-run (for + // example a permission failure after the first payload moved in). + if (currentRunBackupRoots.has(root)) return true; + return isProvenSkillBackupRoot(root); +} + +// isProvenSkillBackupRoot reports whether a stamp root carries the ownership +// marker with the exact expected bytes; anything else is foreign data. +function isProvenSkillBackupRoot(root) { + try { + return fs.readFileSync(path.join(root, SKILL_BACKUP_MARKER_FILE), "utf8") === SKILL_BACKUP_MARKER_BODY; + } catch { + return false; + } +} + +// skillBackupStampPattern matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +const skillBackupStampPattern = /^[0-9]{8}-[0-9]{6}(-[0-9]+)?$/; + +function isSkillBackupStamp(name) { + return skillBackupStampPattern.test(name); +} + +// pruneSkillBackups removes the oldest stamped backup directories once more +// than SKILL_BACKUP_KEEP remain. Only directories whose names match the DWS +// backup stamp format AND whose ownership marker verifies are candidates; +// unknown or unmarked directories are foreign data — preserved and never +// counted against SKILL_BACKUP_KEEP. Stamps sort lexicographically in +// chronological order (`YYYYmmdd-HHMMSS`), so name order is age order. +function pruneSkillBackups(homeDir) { + const root = path.join(homeDir, ".dws", "skill-backups"); + let entries; + try { + entries = fs.readdirSync(root, { withFileTypes: true }); + } catch (err) { + if (err && (err.code === "ENOENT" || err.code === "ENOTDIR")) return; + throw err; + } + const names = entries + .filter((entry) => entry.isDirectory() && isSkillBackupStamp(entry.name)) + .filter((entry) => isProvenSkillBackupRoot(path.join(root, entry.name))) + .map((entry) => entry.name) + .sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + let excess = names.length - SKILL_BACKUP_KEEP; + for (const name of names) { + if (excess <= 0) break; + const candidate = path.join(root, name); + if (currentRunBackupRoots.has(candidate)) continue; + fs.rmSync(candidate, { recursive: true, force: true }); + excess -= 1; + } +} + +function pathExistsLexicallySync(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function copyPathLexicallySync(src, dest) { + const info = fs.lstatSync(src); + if (info.isDirectory()) { + fs.mkdirSync(dest, { mode: 0o700 }); + for (const name of fs.readdirSync(src)) { + copyPathLexicallySync(path.join(src, name), path.join(dest, name)); + } + fs.chmodSync(dest, info.mode & 0o777); + return; + } + if (info.isSymbolicLink()) { + fs.symlinkSync(fs.readlinkSync(src), dest); + return; + } + if (!info.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${info.mode.toString(8)})`); + } + fs.copyFileSync(src, dest, fs.constants.COPYFILE_EXCL); + fs.chmodSync(dest, info.mode & 0o777); +} + +function restoreDirectoryModesSync(modes, chmodFn) { + const failures = []; + for (let i = modes.length - 1; i >= 0; i -= 1) { + const item = modes[i]; + try { + if (pathExistsLexicallySync(item.path)) chmodFn(item.path, item.mode); + } catch (err) { + failures.push(`restore source directory mode ${item.path}: ${err.message}`); + } + } + if (failures.length > 0) throw new Error(failures.join("; ")); +} + +function prepareTreeRemovalSync(root, chmodFn) { + const modes = []; + const visit = (target) => { + const info = fs.lstatSync(target); + if (!info.isDirectory()) return; + const mode = info.mode & 0o777; + if ((mode & 0o700) !== 0o700) { + chmodFn(target, mode | 0o700); + modes.push({ path: target, mode }); + } + for (const name of fs.readdirSync(target)) visit(path.join(target, name)); + }; + try { + visit(root); + return modes; + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function makeTreeWritableBestEffortSync(root) { + try { + const info = fs.lstatSync(root); + if (!info.isDirectory()) return; + try { fs.chmodSync(root, 0o700); } catch {} + let names = []; + try { names = fs.readdirSync(root); } catch {} + for (const name of names) makeTreeWritableBestEffortSync(path.join(root, name)); + } catch {} +} + +function removePublishedSourceSync(src, removeFn, chmodFn) { + const modes = prepareTreeRemovalSync(src, chmodFn); + try { + removeFn(src); + if (pathExistsLexicallySync(src)) throw new Error("source still exists after removal"); + } catch (err) { + try { + restoreDirectoryModesSync(modes, chmodFn); + } catch (restoreErr) { + throw new Error(`${err.message}; ${restoreErr.message}`); + } + throw err; + } +} + +function fileDigestSync(file) { + return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex"); +} + +function verifyPathCopySync(src, dest) { + const srcInfo = fs.lstatSync(src); + const destInfo = fs.lstatSync(dest); + const srcType = srcInfo.mode & fs.constants.S_IFMT; + const destType = destInfo.mode & fs.constants.S_IFMT; + if (srcType !== destType) { + throw new Error(`Skill path type mismatch: ${src} != ${dest}`); + } + if (srcInfo.isDirectory()) { + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill directory mode mismatch: ${src} != ${dest}`); + } + const bytewise = (left, right) => Buffer.from(left).compare(Buffer.from(right)); + const srcNames = fs.readdirSync(src).sort(bytewise); + const destNames = fs.readdirSync(dest).sort(bytewise); + if (srcNames.length !== destNames.length || srcNames.some((name, index) => name !== destNames[index])) { + throw new Error(`Skill directory entries mismatch: ${src} != ${dest}`); + } + for (const name of srcNames) { + verifyPathCopySync(path.join(src, name), path.join(dest, name)); + } + return; + } + if (srcInfo.isSymbolicLink()) { + if (fs.readlinkSync(src) !== fs.readlinkSync(dest)) { + throw new Error(`Skill symlink target mismatch: ${src} != ${dest}`); + } + return; + } + if (!srcInfo.isFile()) { + throw new Error(`unsupported special Skill path ${src} (mode=${srcInfo.mode.toString(8)})`); + } + if ((srcInfo.mode & 0o777) !== (destInfo.mode & 0o777)) { + throw new Error(`Skill file mode mismatch: ${src} != ${dest}`); + } + if (srcInfo.size !== destInfo.size) { + throw new Error(`Skill file size mismatch: ${src} != ${dest}`); + } + if (fileDigestSync(src) !== fileDigestSync(dest)) { + throw new Error(`Skill file digest mismatch: ${src} != ${dest}`); + } +} + +// removeClaimIfEmptySync deletes a claimed destination only while it holds no +// entries. A foreign entry that appeared after the claim must never be +// removed: the rollback has no ownership proof for it. +function removeClaimIfEmptySync(destination) { + try { + fs.rmdirSync(destination); + } catch (err) { + if (err && (err.code === "ENOTEMPTY" || err.code === "EEXIST" || err.code === "EPERM")) { + return; + } + throw err; + } +} + +function destinationExistsError(destination) { + return Object.assign(new Error(`Skill move destination already exists: ${destination}`), { code: "EEXIST" }); +} + +// moveChildIntoClaimNoClobberSync moves one staged child into the claimed +// destination through an atomic no-clobber primitive: directories recursively +// claim with mkdir, regular files publish via fs.linkSync, and symlinks are +// recreated with fs.symlinkSync. Every primitive fails with EEXIST when the +// destination entry was taken concurrently, so no step can replace a foreign +// object the way a plain rename would. +function moveChildIntoClaimNoClobberSync(source, destination, fns) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + // The recursive move leaves an emptied shell at the source. rmdirSync + // only deletes an empty directory, so clearing the shell keeps the + // single-move outcome the parent's rollback expects; a shell that gained + // a concurrent entry fails the removal and retracts the publication + // instead of silently discarding it. + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + removeClaimIfEmptySync(destination); + throw new Error( + `Skill move state uncertain; source shell ${source} removal failed: ${err.message}` + + (rollbackErr ? `; rollback failed: ${rollbackErr.message}` : ""), + ); + } + return; + } + if (sourceStat.isSymbolicLink()) { + const target = fs.readlinkSync(source); + try { + fns.symlinkFn(target, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + let current = null; + try { + current = fs.readlinkSync(destination); + } catch (_) { + // Dest no longer carries our link; leave whatever replaced it. + } + if (current === target) fs.unlinkSync(destination); + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fns.linkFn(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") throw destinationExistsError(destination); + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination, { bigint: true }); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (_) { + // Best effort; the error below names both retained paths. + } + throw err; + } + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +// moveChildrenBackOutOfClaimSync returns the already relocated children to +// the source with plain renames — every one of them is an object this +// transaction published, so a replacing rename is safe — and removes the +// claim only while it is empty. +function moveChildrenBackOutOfClaimSync(source, destination, moved, fns) { + let firstErr = null; + for (let i = moved.length - 1; i >= 0; i -= 1) { + try { + fns.renameFn(path.join(destination, moved[i]), path.join(source, moved[i])); + } catch (err) { + firstErr = firstErr || err; + } + } + try { + removeClaimIfEmptySync(destination); + } catch (err) { + firstErr = firstErr || err; + } + return firstErr; +} + +// moveChildrenIntoClaimSync populates an already-claimed destination from the +// staged source. The claim must still be empty when it runs (a concurrent +// writer landing between the claim and here aborts the publication with the +// destination retained), every child moves through an atomic no-clobber +// primitive, and a live re-read catches a different-named foreign entry that +// landed mid-move. Any failure returns the moved children to the source and +// removes the claim only while it is empty, so foreign data always survives. +function moveChildrenIntoClaimSync(source, destination, sourceMode, fns) { + const claimEntries = fs.readdirSync(destination); + if (claimEntries.length > 0) { + throw destinationExistsError(destination); + } + const entries = fs.readdirSync(source); + const moved = []; + for (const name of entries) { + try { + moveChildIntoClaimNoClobberSync(path.join(source, name), path.join(destination, name), fns); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${err.message}; rollback failed: ${rollbackErr.message}`); + } + throw err; + } + moved.push(name); + } + const live = fs.readdirSync(destination); + if (live.length > entries.length) { + const abortErr = destinationExistsError(destination); + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, moved, fns); + if (rollbackErr) { + throw new Error(`${abortErr.message}; rollback failed: ${rollbackErr.message}`); + } + throw abortErr; + } + fns.chmodFn(destination, sourceMode); +} + +function defaultClaimFns(renameFn) { + return { + mkdirFn: (target) => fs.mkdirSync(target, { mode: 0o700 }), + renameFn: renameFn || fs.renameSync, + linkFn: fs.linkSync, + symlinkFn: fs.symlinkSync, + chmodFn: fs.chmodSync, + }; +} + +function renamePathNoReplaceSync(source, destination, renameFn = fs.renameSync) { + const sourceStat = fs.lstatSync(source); + if (sourceStat.isDirectory() && !sourceStat.isSymbolicLink()) { + const fns = defaultClaimFns(renameFn); + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + moveChildrenIntoClaimSync(source, destination, sourceStat.mode & 0o777, fns); + try { + fs.rmdirSync(source); + } catch (err) { + const rollbackErr = moveChildrenBackOutOfClaimSync(source, destination, fs.readdirSync(destination), fns); + if (rollbackErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${rollbackErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isSymbolicLink()) { + try { + fs.symlinkSync(fs.readlinkSync(source), destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + fs.unlinkSync(destination); + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + if (sourceStat.isFile()) { + try { + fs.linkSync(source, destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw destinationExistsError(destination); + } + throw err; + } + try { + fs.unlinkSync(source); + } catch (err) { + try { + const destStat = fs.lstatSync(destination); + if (destStat.ino === sourceStat.ino && destStat.dev === sourceStat.dev) { + fs.unlinkSync(destination); + } + } catch (retractErr) { + throw new Error(`Skill move state uncertain; source ${source} and dest ${destination} retained: ${err.message}; retract failed: ${retractErr.message}`); + } + throw err; + } + return; + } + throw new Error(`unsupported Skill path type for no-replace move: ${source}`); +} + +function recordSkillPathPublicationSync(destination) { + const stat = fs.lstatSync(destination, { bigint: true }); + return { + destination, + device: stat.dev.toString(), + inode: stat.ino.toString(), + fingerprint: skillPathFingerprint(destination), + }; +} + +function retractUnconfirmedSkillPublicationSync(src, dest, publication, cause, options = {}) { + try { + rollbackPublishedSkillPath(publication, { + quarantineRenameFn: options.quarantineRenameFn, + restoreRenameFn: options.restoreRenameFn || options.renameFn, + removeFn: options.removeFn, + }); + } catch (retractErr) { + throw new Error(`Skill move state uncertain: ${cause.message}; failed to retract ${dest}: ${retractErr.message}; source ${src} and dest ${dest} retained`); + } + throw new Error(`Skill move failed, dest retracted, source retained (${src}): ${cause.message}`); +} + +function movePathRecoverablySync(src, dest, options = {}) { + const renameFn = options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const copyFn = options.copyFn || copyPathLexicallySync; + const verifyFn = options.verifyFn || verifyPathCopySync; + const removeFn = options.removeFn || ((target) => fs.rmSync(target, { recursive: true, force: true })); + const mkdirTempFn = options.mkdirTempFn || fs.mkdtempSync; + const chmodFn = options.chmodFn || fs.chmodSync; + if (pathExistsLexicallySync(dest)) { + throw new Error(`move destination already exists: ${dest}`); + } + fs.mkdirSync(path.dirname(dest), { recursive: true }); + try { + renameFn(src, dest); + return; + } catch (err) { + if (!err || err.code !== "EXDEV") throw err; + } + + const stageRoot = mkdirTempFn(path.join(path.dirname(dest), `.${path.basename(dest)}.cross-device-`)); + const stage = path.join(stageRoot, "payload"); + let stageCleaned = false; + let destOccupied = false; + try { + copyFn(src, stage); + verifyFn(src, stage); + const stageInfo = fs.lstatSync(stage); + const stageMode = stageInfo.mode & 0o777; + if (stageInfo.isDirectory()) chmodFn(stage, stageMode | 0o700); + // Dest publication is always no-replace. The injected renameFn is the + // same-volume EXDEV probe; using it here would re-open a replacing + // rename on Windows (MOVEFILE_REPLACE_EXISTING). + renamePathNoReplaceSync(stage, dest, renameFn); + destOccupied = true; + let publication; + try { + publication = recordSkillPathPublicationSync(dest); + } catch (recErr) { + if (!pathExistsLexicallySync(dest)) throw recErr; + throw new Error(`Skill move state uncertain: published dest ${dest} could not be recorded: ${recErr.message}; source ${src} and dest ${dest} retained`); + } + try { + if (stageInfo.isDirectory()) { + chmodFn(dest, stageMode); + publication = recordSkillPathPublicationSync(dest); + } + verifyFn(src, dest); + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + } catch (postErr) { + retractUnconfirmedSkillPublicationSync(src, dest, publication, postErr, { + removeFn, + }); + } + try { + removePublishedSourceSync(src, removeFn, chmodFn); + } catch (err) { + throw new Error(`verified target published but source removal failed; both copies retained (${src}, ${dest}): ${err.message}`); + } + if (pathExistsLexicallySync(src)) { + throw new Error(`verified target published but source still exists; both copies retained (${src}, ${dest})`); + } + } catch (err) { + if (!stageCleaned) { + try { + makeTreeWritableBestEffortSync(stageRoot); + removeFn(stageRoot); + stageCleaned = true; + } catch (cleanupErr) { + if (!destOccupied) { + throw new Error(`${err.message}; cross-device staging cleanup failed: ${cleanupErr.message}`); + } + } + } + throw err; + } +} + +function skillMoveOptions(options = {}) { + return { + renameFn: options.renameFn, + copyFn: options.backupCopyFn, + verifyFn: options.backupVerifyFn, + removeFn: options.backupRemoveFn, + mkdirTempFn: options.backupMkdirTempFn, + }; +} + // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it (non-interactive // installs cannot confirm, so removals must stay reversible). Missing paths // are a no-op success. On any backup failure the directory is left in place -// and false is returned so callers skip that target rather than silently -// deleting data. -function backupAndRemoveSkillDir(homeDir, dir, backups = null, renameFn = fs.renameSync) { - if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { - return true; +// and an error is returned so callers cannot silently delete data. +function backupAndRemoveSkillDir(homeDir, dir, backups = null, options = {}) { + try { + fs.lstatSync(dir); + } catch (err) { + if (err && err.code === "ENOENT") return ""; + throw err; } const rel = path.relative(homeDir, dir); const name = rel && rel !== "." && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("-") : path.basename(dir); - const stamp = backupStamp(); + const stamp = (options.backupStampFn || backupStamp)(); const backupRoot = path.join(homeDir, ".dws", "skill-backups"); let targetRoot = path.join(backupRoot, stamp); let target = path.join(targetRoot, name); - for (let i = 1; fs.existsSync(target); i++) { + // Bump not only when the payload path is taken but also when the stamp + // root exists without a verified ownership marker: a same-second foreign + // directory must never be stamped DWS-owned and made prunable. A + // marker-verified root from this run's same second stays reusable. + for (let i = 1; pathExistsLexicallySync(target) || !skillBackupRootUsable(targetRoot); i++) { if (i > 1000) { - console.warn(`⚠️ 备份目录冲突,保留原目录 ${dir}`); - return false; + throw new Error(`backup directory collision limit exceeded; source retained: ${dir}`); } targetRoot = path.join(backupRoot, `${stamp}-${i}`); target = path.join(targetRoot, name); } try { fs.mkdirSync(targetRoot, { recursive: true }); - renameFn(dir, target); + // Stamp ownership immediately after creating the stamp root and before + // the skill directory moves into it, so an interrupted backup can never + // leave an unmarked (never-prunable) stamp behind. + writeSkillBackupMarker(targetRoot); + } catch (err) { + // Never leave an empty unowned stamp root behind. rmdirSync removes an + // empty root but deliberately fails on a non-empty one, so a pre-existing + // root holding foreign data is never destroyed. + try { + fs.rmdirSync(targetRoot); + } catch { + // Non-empty pre-existing root: foreign data stays. + } + console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); + } + try { + movePathRecoverablySync(dir, target, options); } catch (err) { console.warn(`⚠️ 备份失败,保留原目录 ${dir}: ${err.message}`); - return false; + throw new Error(`failed to back up Skill directory ${dir}: ${err.message}`); } if (backups) { backups.push({ original: dir, backup: target }); } + currentRunBackupRoots.add(targetRoot); + // Keep the backup root bounded. The backup itself already succeeded, so a + // prune failure is only a warning (matches Go's best-effort prune call). + try { + pruneSkillBackups(homeDir); + } catch (err) { + console.warn(`⚠️ 旧备份清理失败(备份本身已成功): ${err.message}`); + } console.log(` × 已备份并移除 ${dir} → ${target}`); - return true; + return target; } function findBinary(root) { @@ -234,15 +940,11 @@ function installSkillsToHomes(skillRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -256,37 +958,48 @@ function installSkillsToHomes(skillRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); - - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) { + installed += 1; + } else { + failed += 1; } - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (universal) { + // Cleanup-only migration: universal Agents read the canonical store + // directly, so nothing is installed here. A retire failure leaves an + // obsolete copy in place — worth a warning, never a reason to fail an + // install whose canonical store and links all succeeded. + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + victims.push(path.join(baseDir, entry.name)); + } + } + } + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, ["dws"], victims); + installed += 1; + } catch (linkErr) { + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; + } } } if (installed === 0) { @@ -296,6 +1009,10 @@ function installSkillsToHomes(skillRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 mono Skill 失败`); } fs.rmSync(path.join(skillStateDir(homeDir), "skills-state.json"), { force: true }); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // multiTreeHasSkills mirrors multi_tree_has_skills in scripts/install.sh and @@ -346,12 +1063,12 @@ function isManagedMultiSkillDir(dir, managedNames) { return LEGACY_OFFICIAL_MULTI_SKILLS.has(name) || managedNames.has(name); } -function retireGenericSkillRoot(homeDir, managedNames) { - const baseDir = path.join(homeDir, ".agents", "skills"); +function retireManagedSkillRoot(homeDir, baseDir, managedNames, options = {}) { + const moveOptions = skillMoveOptions(options); const victims = [path.join(baseDir, "dws")]; if (fs.existsSync(baseDir)) { for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { - if (entry.isDirectory() && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { + if ((entry.isDirectory() || entry.isSymbolicLink()) && isManagedMultiSkillDir(path.join(baseDir, entry.name), managedNames)) { victims.push(path.join(baseDir, entry.name)); } } @@ -359,27 +1076,345 @@ function retireGenericSkillRoot(homeDir, managedNames) { const backups = []; try { for (const victim of victims) { - if (!backupAndRemoveSkillDir(homeDir, victim, backups)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } } catch (err) { const restoreErrors = []; for (let i = backups.length - 1; i >= 0; i -= 1) { try { fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); - fs.renameSync(backups[i].backup, backups[i].original); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); } catch (restoreErr) { restoreErrors.push(`${backups[i].original}: ${restoreErr.message}`); } } if (restoreErrors.length > 0) { - throw new Error(`${err.message}; generic-root rollback failed: ${restoreErrors.join("; ")}`); + throw new Error(`${err.message}; Agent-root rollback failed: ${restoreErrors.join("; ")}`); } throw err; } } +function samePhysicalDir(left, right) { + try { + const leftReal = fs.realpathSync(left); + const rightReal = fs.realpathSync(right); + return process.platform === "win32" ? leftReal.toLowerCase() === rightReal.toLowerCase() : leftReal === rightReal; + } catch (_) { + return false; + } +} + +function writeSkillFingerprintField(hash, value) { + const bytes = Buffer.from(String(value), "utf8"); + hash.update(String(bytes.length), "utf8"); + hash.update(":", "utf8"); + hash.update(bytes); +} + +function skillPathFingerprint(target) { + const hash = crypto.createHash("sha256"); + const visit = (current) => { + const stat = fs.lstatSync(current, { bigint: true }); + writeSkillFingerprintField(hash, Number(stat.mode & 0o170000n)); + writeSkillFingerprintField(hash, Number(stat.mode & 0o777n)); + if (stat.isDirectory()) { + const names = fs.readdirSync(current).sort((a, b) => Buffer.from(a).compare(Buffer.from(b))); + writeSkillFingerprintField(hash, names.length); + for (const name of names) { + writeSkillFingerprintField(hash, name); + visit(path.join(current, name)); + } + return; + } + if (stat.isSymbolicLink()) { + writeSkillFingerprintField(hash, fs.readlinkSync(current)); + return; + } + if (stat.isFile()) { + writeSkillFingerprintField(hash, stat.size.toString()); + hash.update(fs.readFileSync(current)); + return; + } + throw new Error(`unsupported Skill path type: ${current}`); + }; + visit(target); + return hash.digest("hex"); +} + +function skillPublicationMatches(target, publication) { + const stat = fs.lstatSync(target, { bigint: true }); + return stat.dev.toString() === publication.device + && stat.ino.toString() === publication.inode + && skillPathFingerprint(target) === publication.fingerprint; +} + +function removeSkillPathLexically(target) { + const stat = fs.lstatSync(target); + if (stat.isDirectory() && !stat.isSymbolicLink()) { + fs.rmSync(target, { recursive: true, force: true }); + } else { + fs.unlinkSync(target); + } +} + +function skillPathExistsLexically(target) { + try { + fs.lstatSync(target); + return true; + } catch (err) { + if (err && err.code === "ENOENT") return false; + throw err; + } +} + +function rollbackPublishedSkillPath(publication, options = {}) { + // Prove ownership at dest first. Quarantining before that check would move + // a concurrent replacement off its original path; if the later match fails + // and restore cannot republish, user data stays hidden under .rollback-*. + // Quarantine itself is a same-inode rename — a no-replace mkdir-claim + // would mint a new identity and make this transaction's own dest look + // foreign. A post-quarantine mismatch is restored with no-replace. + const quarantineRenameFn = options.quarantineRenameFn || fs.renameSync; + const restoreRenameFn = options.restoreRenameFn || options.renameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const removeFn = options.removeFn || removeSkillPathLexically; + const destination = publication.destination; + const quarantineRoot = fs.mkdtempSync(path.join(path.dirname(destination), `.${path.basename(destination)}.rollback-`)); + const quarantine = path.join(quarantineRoot, "payload"); + const cleanupRoot = () => { + try { + fs.rmSync(quarantineRoot, { recursive: true, force: true }); + } catch (cleanupErr) { + throw new Error(`failed to clean Skill rollback quarantine ${quarantineRoot}: ${cleanupErr.message}`); + } + }; + try { + if (!skillPublicationMatches(destination, publication)) { + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); + } + } catch (err) { + if (err && err.code === "ENOENT") { + // ENOENT from the fingerprint walk means the destination itself is + // gone only when it no longer exists lexically; a concurrently removed + // CHILD produces the same error and must fall through to the refusal + // below instead of skipping rollback. + if (!pathExistsLexicallySync(destination)) { + cleanupRoot(); + return; + } + } + if (String(err.message || "").startsWith("refusing to delete non-transaction Skill") + || String(err.message || "").startsWith("failed to clean Skill rollback quarantine")) { + throw err; + } + cleanupRoot(); + throw new Error(`refusing to delete unverifiable Skill ${destination}: ${err.message}`); + } + + try { + quarantineRenameFn(destination, quarantine); + } catch (err) { + cleanupRoot(); + if (err && err.code === "ENOENT") return; + throw new Error(`failed to quarantine published Skill ${destination}: ${err.message}`); + } + let owned = false; + try { + owned = skillPublicationMatches(quarantine, publication); + } catch (err) { + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: ${err.message}`); + } + if (!owned) { + try { + restoreRenameFn(quarantine, destination); + } catch (restoreErr) { + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed; concurrent object retained at ${quarantine}; restore failed: ${restoreErr.message}`); + } + cleanupRoot(); + throw new Error(`refusing to delete non-transaction Skill ${destination}: concurrent object identity changed`); + } + removeFn(quarantine); + cleanupRoot(); +} + +// Publish a staged Skill directory with a truly atomic no-replace claim. +// fs.mkdirSync fails with EEXIST when anything occupies the destination, so +// the claim itself is the existence check — there is no check-then-rename +// window a concurrent file, symlink, or empty directory could slip through +// (fs.renameSync replaces the destination outright on every platform, +// including Windows where libuv passes MOVEFILE_REPLACE_EXISTING). Children +// then move into the claimed directory one by one; each child rename targets +// a path inside a directory this transaction owns, so no step can replace a +// foreign object. The publication identity is the claim's: it is captured +// after the claim succeeds and re-verified by the caller once the move +// completes. On a failed child move the relocated children are moved back and +// only the (now empty) claim is removed, so the destination returns to the +// foreign object or to nothing — never to a partial publication. +function publishSkillDirNoReplace(staged, destination, options = {}) { + const fns = { + mkdirFn: options.publishMkdirFn || ((target) => fs.mkdirSync(target, { mode: 0o700 })), + renameFn: options.publishRenameFn || fs.renameSync, + linkFn: options.publishLinkFn || fs.linkSync, + symlinkFn: options.publishSymlinkFn || fs.symlinkSync, + chmodFn: options.publishChmodFn || fs.chmodSync, + }; + const sourceStat = fs.lstatSync(staged); + try { + fns.mkdirFn(destination); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + const claimStat = fs.lstatSync(destination, { bigint: true }); + const publication = { + destination, + device: claimStat.dev.toString(), + inode: claimStat.ino.toString(), + fingerprint: skillPathFingerprint(staged), + }; + moveChildrenIntoClaimSync(staged, destination, sourceStat.mode & 0o7777, fns); + return publication; +} + +// Publish a staged canonical link by creating the link directly at the +// destination. Link creation is the atomic no-replace primitive on every +// platform: the kernel refuses with EEXIST when anything — file, symlink, or +// directory — already occupies the path, and unlike a rename (which Windows +// performs with MOVEFILE_REPLACE_EXISTING) or `ln -P source target` (which +// links INTO a directory that appeared at the target) it has no +// check-then-publish window and never treats the destination as a container. +// Staging still proves beforehand that links can be created on this +// filesystem, before any victim moves. The publication identity is captured +// from the destination after creation — no primitive carries the staged +// inode across this publish — and confirmation re-reads the live path, so a +// concurrent replacement surfaces before the publication enters the rollback +// list instead of being recorded as owned. +function publishCanonicalLinkNoReplace(staged, destination, options = {}) { + const linkTarget = fs.readlinkSync(staged); + const linkType = process.platform === "win32" ? "junction" : "dir"; + const symlinkFn = options.publishSymlinkFn || fs.symlinkSync; + try { + symlinkFn(linkTarget, destination, linkType); + } catch (err) { + if (err && err.code === "EEXIST") { + throw Object.assign(new Error(`Skill publish destination already exists: ${destination}`), { code: "EEXIST" }); + } + throw err; + } + let publication; + try { + publication = recordSkillPathPublicationSync(destination); + } catch (recErr) { + if (!skillPathExistsLexically(destination)) throw recErr; + throw new Error(`Skill publish state uncertain: dest ${destination} occupied but unrecorded: ${recErr.message}`); + } + let destStat; + try { + destStat = fs.lstatSync(destination, { bigint: true }); + } catch (err) { + throw new Error(`published Skill identity changed before confirmation: ${destination}: ${err.message}`); + } + if (!destStat.isSymbolicLink() || fs.readlinkSync(destination) !== linkTarget) { + // Dest is not the link this transaction created. Leave the occupant. + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + const sameInode = publication + && destStat.dev.toString() === publication.device + && destStat.ino.toString() === publication.inode; + if (!publication || !sameInode || !skillPublicationMatches(destination, publication)) { + if (sameInode) { + try { + rollbackPublishedSkillPath(publication); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: published dest ${destination} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${destination}`); + } + return publication; +} + +function publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, names, victims, options = {}) { + const moveOptions = skillMoveOptions(options); + const publishRemoveFn = options.publishRemoveFn || removeSkillPathLexically; + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + // Injectable so the Windows junction type and absolute link target can be + // asserted from a non-Windows host. + const symlinkFn = options.symlinkFn || fs.symlinkSync; + fs.mkdirSync(baseDir, { recursive: true }); + const realBaseDir = fs.realpathSync(baseDir); + const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-link-set.tmp-")); + const staged = []; + const backups = []; + const published = []; + const correctLinks = new Set(); + const restore = () => { + const restoreErrors = []; + for (let i = published.length - 1; i >= 0; i -= 1) { + try { + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn: publishRemoveFn }); + } catch (err) { + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); + } + } + for (let i = backups.length - 1; i >= 0; i -= 1) { + try { + fs.mkdirSync(path.dirname(backups[i].original), { recursive: true }); + movePathRecoverablySync(backups[i].backup, backups[i].original, moveOptions); + } catch (err) { + restoreErrors.push(`restore ${backups[i].original} from ${backups[i].backup}: ${err.message}`); + } + } + if (restoreErrors.length > 0) { + throw new Error(restoreErrors.join("; ")); + } + }; + try { + for (const name of names) { + const target = path.join(canonicalBase, name); + const dest = path.join(baseDir, name); + if (samePhysicalDir(dest, target)) { + correctLinks.add(path.resolve(dest)); + continue; + } + const stagedPath = path.join(stageRoot, name); + const realTarget = fs.realpathSync(target); + const linkTarget = process.platform === "win32" ? realTarget : path.relative(realBaseDir, realTarget); + symlinkFn(linkTarget, stagedPath, process.platform === "win32" ? "junction" : "dir"); + staged.push({ staged: stagedPath, dest: path.join(baseDir, name) }); + } + const seen = new Set(); + for (const victim of victims) { + const normalized = path.resolve(victim); + if (seen.has(normalized) || correctLinks.has(normalized)) continue; + seen.add(normalized); + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); + } + for (const item of staged) { + const publication = publishCanonicalLinkNoReplace(item.staged, item.dest, options); + published.push(publication); + } + } catch (err) { + try { + restore(); + } catch (restoreErr) { + throw new Error(`${err.message}; rollback failed: ${restoreErr.message}`); + } + throw err; + } finally { + fs.rmSync(stageRoot, { recursive: true, force: true }); + } +} + function skillDirectoryDigest(dir) { const files = []; const visit = (current, prefix) => { @@ -418,8 +1453,9 @@ function publishManagedMultiSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-multi-set.tmp-")); const staged = []; @@ -430,16 +1466,16 @@ function publishManagedMultiSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -463,14 +1499,28 @@ function publishManagedMultiSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } for (const item of staged) { - renameFn(item.staged, item.dest); - published.push(item.dest); + const publication = publishSkillDirNoReplace(item.staged, item.dest, options); + if (!skillPublicationMatches(item.dest, publication)) { + let live = null; + try { + live = fs.lstatSync(item.dest, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${item.dest} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${item.dest}`); + } + published.push(publication); } } catch (err) { try { @@ -495,8 +1545,9 @@ function publishManagedMonoSkillSetAtomically( options = {}, ) { const copyFn = options.copyFn || copyChildren; - const renameFn = options.renameFn || fs.renameSync; const removeFn = options.removeFn || ((dir) => fs.rmSync(dir, { recursive: true, force: true })); + const rollbackRenameFn = options.rollbackRenameFn || ((source, target) => renamePathNoReplaceSync(source, target)); + const moveOptions = skillMoveOptions(options); fs.mkdirSync(baseDir, { recursive: true }); const stageRoot = fs.mkdtempSync(path.join(baseDir, ".dws-mono-set.tmp-")); const stagedDir = path.join(stageRoot, "dws"); @@ -508,16 +1559,16 @@ function publishManagedMonoSkillSetAtomically( const restoreErrors = []; for (let i = published.length - 1; i >= 0; i -= 1) { try { - removeFn(published[i]); + rollbackPublishedSkillPath(published[i], { restoreRenameFn: rollbackRenameFn, removeFn }); } catch (err) { - restoreErrors.push(`remove ${published[i]}: ${err.message}`); + restoreErrors.push(`remove ${published[i].destination}: ${err.message}`); } } for (let i = backups.length - 1; i >= 0; i -= 1) { const item = backups[i]; try { fs.mkdirSync(path.dirname(item.original), { recursive: true }); - renameFn(item.backup, item.original); + movePathRecoverablySync(item.backup, item.original, moveOptions); } catch (err) { restoreErrors.push(`restore ${item.original} from ${item.backup}: ${err.message}`); } @@ -537,13 +1588,27 @@ function publishManagedMonoSkillSetAtomically( continue; } seen.add(normalized); - if (!backupAndRemoveSkillDir(homeDir, victim, backups, renameFn)) { - throw new Error(`failed to back up Skill directory ${victim}`); - } + backupAndRemoveSkillDir(homeDir, victim, backups, moveOptions); } - published.push(destDir); - renameFn(stagedDir, destDir); + const publication = publishSkillDirNoReplace(stagedDir, destDir, options); + if (!skillPublicationMatches(destDir, publication)) { + let live = null; + try { + live = fs.lstatSync(destDir, { bigint: true }); + } catch (_) { + live = null; + } + if (live && live.dev.toString() === publication.device && live.ino.toString() === publication.inode) { + try { + rollbackPublishedSkillPath(publication, { restoreRenameFn: rollbackRenameFn, removeFn }); + } catch (retractErr) { + throw new Error(`Skill publish state uncertain: dest ${destDir} confirmation failed; retract failed: ${retractErr.message}`); + } + } + throw new Error(`published Skill identity changed before confirmation: ${destDir}`); + } + published.push(publication); } catch (err) { try { restore(); @@ -628,10 +1693,6 @@ function installMultiSkillsToHomes(multiRoot) { let attempted = 0; let failed = 0; - const specificAgentDirs = AGENT_DIRS.slice(1).filter((agentDir) => - fs.existsSync(path.dirname(path.join(homeDir, agentDir))), - ); - const installToBase = (baseDir) => { fs.mkdirSync(baseDir, { recursive: true }); const victims = [path.join(baseDir, "dws")]; @@ -639,7 +1700,7 @@ function installMultiSkillsToHomes(multiRoot) { // the same transaction as every replaced bundled skill. for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { if ( - entry.isDirectory() && + (entry.isDirectory() || entry.isSymbolicLink()) && (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && !skillSet.has(entry.name) ) { @@ -658,37 +1719,48 @@ function installMultiSkillsToHomes(multiRoot) { return true; }; - AGENT_DIRS.forEach((agentDir, index) => { - if (index === 0 && specificAgentDirs.length > 0) { - return; - } - const baseDir = path.join(homeDir, agentDir); - const parentGate = path.dirname(baseDir); - if (index > 0 && !fs.existsSync(parentGate)) { - return; - } - attempted += 1; - if (installToBase(baseDir)) { - installed += 1; - } else { - failed += 1; - } - }); + const canonicalBase = path.join(homeDir, ".agents", "skills"); + attempted += 1; + if (installToBase(canonicalBase)) installed += 1; + else failed += 1; - if (specificAgentDirs.length > 0 && installed > 0) { - try { - retireGenericSkillRoot(homeDir, managedNames); - } catch (err) { - console.warn(`⚠️ 通用 Skill 副本迁移失败: ${err.message}`); - failed += 1; - } - } - - if (attempted === 0) { - if (installToBase(path.join(homeDir, ".agents", "skills"))) { - installed += 1; - } else { - failed += 1; + if (installed > 0) { + for (const target of resolvedAgentTargets(homeDir)) { + const { agentDir, baseDir, universal } = target; + if (!agentTargetDetected(target) || samePhysicalDir(baseDir, canonicalBase)) continue; + attempted += 1; + if (universal) { + // Cleanup-only migration (see installSkillsToHomes): nothing is + // installed in a universal root, so a retire failure must not fail an + // otherwise complete install. Go records the same case as a failed + // directory yet still returns success for the run. + try { + retireManagedSkillRoot(homeDir, baseDir, managedNames); + } catch (err) { + console.warn(`⚠️ Agent Skill 旧副本迁移失败(不影响本次安装)${baseDir}: ${err.message}`); + } + continue; + } + const victims = [path.join(baseDir, "dws")]; + if (fs.existsSync(baseDir)) { + for (const entry of fs.readdirSync(baseDir, { withFileTypes: true })) { + if ( + (entry.isDirectory() || entry.isSymbolicLink()) && + (LEGACY_OFFICIAL_MULTI_SKILLS.has(entry.name) || managedNames.has(entry.name)) && + !skillSet.has(entry.name) + ) victims.push(path.join(baseDir, entry.name)); + } + } + for (const name of skills) victims.push(path.join(baseDir, name)); + try { + publishCanonicalLinksAtomically(homeDir, canonicalBase, baseDir, skills, victims); + installed += 1; + } catch (linkErr) { + if (installToBase(baseDir)) { + console.log(`ℹ️ ${baseDir} 已自动使用兼容方式安装,可正常使用`); + installed += 1; + } else failed += 1; + } } } if (installed === 0) { @@ -698,6 +1770,10 @@ function installMultiSkillsToHomes(multiRoot) { throw new Error(`有 ${failed} 个 Agent 目标安装 multi Skill 失败`); } writeSkillsState(homeDir, multiRoot, skills); + console.log(`✅ DWS Skills 安装完成`); + console.log(` 统一安装位置:${canonicalBase}`); + console.log(` 已自动适配本机上检测到的 Agent`); + console.log(`ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效`); } // resolveSkillMode mirrors scripts/install.sh: DWS_SKILL_MODE (mono|multi) @@ -812,7 +1888,19 @@ if (require.main === module) { } module.exports = { + UPSTREAM_AGENTS, + resolvedAgentTargets, + agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, + backupAndRemoveSkillDir, + copyPathLexicallySync, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, + verifyPathCopySync, }; diff --git a/build/npm/package.json.tmpl b/build/npm/package.json.tmpl index f1d70d18..b7d34154 100644 --- a/build/npm/package.json.tmpl +++ b/build/npm/package.json.tmpl @@ -32,6 +32,6 @@ "README.md" ], "engines": { - "node": ">=16" + "node": ">=16.7.0" } } diff --git a/cmd/interface-snapshot/main.go b/cmd/interface-snapshot/main.go index bfaab482..f00252c9 100644 --- a/cmd/interface-snapshot/main.go +++ b/cmd/interface-snapshot/main.go @@ -157,6 +157,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "", "candidate flag migration manifest", ) + approvedCommandMigrationsPath := flags.String( + "approved-command-migrations", + "", + "merge-base-owned approved command migration manifest", + ) + candidateCommandMigrationsPath := flags.String( + "candidate-command-migrations", + "", + "candidate command migration manifest", + ) if err := flags.Parse(args); err != nil { return false, err } @@ -174,8 +184,13 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { "--approved-flag-migrations and --candidate-flag-migrations must be provided together", ) } - if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") { - return false, fmt.Errorf("flag migration compare requires both --base and --stable") + if (*approvedCommandMigrationsPath == "") != (*candidateCommandMigrationsPath == "") { + return false, fmt.Errorf( + "--approved-command-migrations and --candidate-command-migrations must be provided together", + ) + } + if (*approvedMigrationsPath != "" || *approvedCommandMigrationsPath != "") && (*basePath == "" || *stablePath == "") { + return false, fmt.Errorf("migration compare requires both --base and --stable") } current, err := readSnapshot(*currentPath) @@ -197,7 +212,39 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) { } report := interfacesnapshot.CompareAll(current, references) - if *approvedMigrationsPath != "" { + if *approvedCommandMigrationsPath != "" { + flagApproved := interfacesnapshot.FlagMigrationManifest{Version: interfacesnapshot.FlagMigrationManifestVersion, Migrations: []interfacesnapshot.FlagMigration{}} + flagCandidate := flagApproved + if *approvedMigrationsPath != "" { + flagApproved, err = readFlagMigrationManifest(*approvedMigrationsPath) + if err != nil { + return false, fmt.Errorf("read approved flag migrations: %w", err) + } + flagCandidate, err = readFlagMigrationManifest(*candidateMigrationsPath) + if err != nil { + return false, fmt.Errorf("read candidate flag migrations: %w", err) + } + } + commandApproved, readErr := readCommandMigrationManifest(*approvedCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read approved command migrations: %w", readErr) + } + commandCandidate, readErr := readCommandMigrationManifest(*candidateCommandMigrationsPath) + if readErr != nil { + return false, fmt.Errorf("read candidate command migrations: %w", readErr) + } + report, err = interfacesnapshot.CompareAllWithInterfaceMigrations( + current, + references, + flagApproved, + flagCandidate, + commandApproved, + commandCandidate, + ) + if err != nil { + return false, fmt.Errorf("validate interface migration lifecycle: %w", err) + } + } else if *approvedMigrationsPath != "" { approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath) if readErr != nil { return false, fmt.Errorf("read approved flag migrations: %w", readErr) @@ -234,6 +281,15 @@ func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationMani return interfacesnapshot.ReadFlagMigrationManifest(file) } +func readCommandMigrationManifest(path string) (interfacesnapshot.CommandMigrationManifest, error) { + file, err := os.Open(filepath.Clean(path)) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + defer file.Close() + return interfacesnapshot.ReadCommandMigrationManifest(file) +} + func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error { for _, command := range snapshot.Commands { path := strings.TrimPrefix(command.Path, "dws") @@ -280,5 +336,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) { func printUsage(w io.Writer) { fmt.Fprintln(w, "usage:") fmt.Fprintln(w, " interface-snapshot generate [--output FILE]") - fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]") + fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE] [--approved-command-migrations FILE --candidate-command-migrations FILE]") } diff --git a/cmd/interface-snapshot/main_test.go b/cmd/interface-snapshot/main_test.go index 62bb91cb..7bb44fb7 100644 --- a/cmd/interface-snapshot/main_test.go +++ b/cmd/interface-snapshot/main_test.go @@ -166,6 +166,102 @@ func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testi } } +func TestCrossPlatformCoverageRunCompareCommandMigrationInputs(t *testing.T) { + dir := t.TempDir() + snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws")) + emptyFlag := writeManifest(t, dir, "empty-flags.json", `{"version":1,"migrations":[]}`) + emptyCommand := writeManifest(t, dir, "empty-commands.json", `{"version":1,"migrations":[]}`) + invalid := writeManifest(t, dir, "invalid-commands.json", `{`) + + var stdout, stderr bytes.Buffer + args := []string{ + "compare", + "--current", snapshotPath, + "--base", snapshotPath, + "--stable", snapshotPath, + "--approved-flag-migrations", emptyFlag, + "--candidate-flag-migrations", emptyFlag, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(args, &stdout, &stderr); exitCode != 0 { + t.Fatalf("combined migration compare exit=%d stderr=%s", exitCode, stderr.String()) + } + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved flag", invalid, emptyFlag, "read approved flag migrations"}, + {"candidate flag", emptyFlag, invalid, "read candidate flag migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-flag-migrations", test.approved, + "--candidate-flag-migrations", test.candidate, + "--approved-command-migrations", emptyCommand, + "--candidate-command-migrations", emptyCommand, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("combined flag error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + for _, test := range []struct { + name string + approved string + candidate string + want string + }{ + {"approved", invalid, emptyCommand, "read approved command migrations"}, + {"candidate", emptyCommand, invalid, "read candidate command migrations"}, + } { + t.Run(test.name, func(t *testing.T) { + stdout.Reset() + stderr.Reset() + testArgs := []string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", test.approved, + "--candidate-command-migrations", test.candidate, + } + if exitCode := run(testArgs, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), test.want) { + t.Fatalf("command manifest error exit=%d stderr=%s", exitCode, stderr.String()) + } + }) + } + + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, + "--approved-command-migrations", emptyCommand, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "provided together") { + t.Fatalf("one-sided command manifest exit=%d stderr=%s", exitCode, stderr.String()) + } + + if _, err := readCommandMigrationManifest(filepath.Join(dir, "missing.json")); err == nil { + t.Fatal("missing command migration manifest unexpectedly read") + } + if _, err := readCommandMigrationManifest(invalid); err == nil { + t.Fatal("invalid command migration manifest unexpectedly read") + } + + pending := writeManifest(t, dir, "pending-command.json", commandMigrationManifestJSON("pending")) + consumed := writeManifest(t, dir, "consumed-command.json", commandMigrationManifestJSON("consumed")) + stderr.Reset() + if exitCode := run([]string{ + "compare", "--current", snapshotPath, "--base", snapshotPath, "--stable", snapshotPath, + "--approved-command-migrations", pending, + "--candidate-command-migrations", consumed, + }, &stdout, &stderr); exitCode != 2 || !strings.Contains(stderr.String(), "validate interface migration lifecycle") { + t.Fatalf("command lifecycle error exit=%d stderr=%s", exitCode, stderr.String()) + } +} + func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) { dir := t.TempDir() currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws")) @@ -567,6 +663,33 @@ func flagMigrationManifestJSON(state string) string { }`, "STATE", state, 1) } +func commandMigrationManifestJSON(state string) string { + return strings.Replace(`{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.move", + "replacement_tool_id": "chat.move", + "parameters": [] + }, + "state": "STATE", + "reason": "reviewed command migration" + }] +}`, "STATE", state, 1) +} + func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool { for _, flag := range flags { if flag.Name == name && flag.Type == flagType { diff --git a/docs/ci-pr-gates.md b/docs/ci-pr-gates.md index d34f3679..8016833d 100644 --- a/docs/ci-pr-gates.md +++ b/docs/ci-pr-gates.md @@ -184,11 +184,11 @@ candidate SHA。 `check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准 flag 迁移。 -Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag -migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整 -Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface -lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会 -放宽其他 Schema 历史字段。 +Schema compatibility 使用同一组 base、stable、candidate refs,以及 base-owned flag +与 command migration ledgers。merge-base-owned checker 分别规范化 merge-base 与 +stable 的完整 Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 +Interface lifecycle 的 exact rename、command move 或 flag extraction 规范化到当前历史 +副本,不会维护第二份 allowlist,也不会放宽其他 Schema 历史字段。 For a release-seal branch that archives rendered fragments: diff --git a/docs/cli-interface-flag-migrations.md b/docs/cli-interface-flag-migrations.md index d4a50124..96acf2cb 100644 --- a/docs/cli-interface-flag-migrations.md +++ b/docs/cli-interface-flag-migrations.md @@ -1,6 +1,13 @@ -# CLI flag 兼容迁移治理 +# CLI Help / Schema 兼容迁移治理 -本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。 +本文定义两种受控 flag 迁移: + +1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。 +2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。 + +两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。 + +同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。 同名 flag 的精确类型迁移属于另一类评审机制,只能进入 `internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename @@ -31,7 +38,7 @@ Smoke fixture,不参与迁移审批。 同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免 调用方因漏传历史参考而提前清理 consumed receipt。 -PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 +PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 flag 机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空 flag 清单。后续引入 command migration 扩展时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,因此允许随治理 PR 提交仍处于 before 的 pending 计划,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。 这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。 @@ -39,22 +46,80 @@ PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁 ```text scripts/policy/interface-migrations/approved-flag-migrations-v1.json +scripts/policy/interface-migrations/approved-command-migrations-v1.json ``` -清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 +清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。 + +首次引入一个旧 merge-base 不认识的新 `kind` 时,机制 PR 不得同时写入该 kind 的 pending 记录,因为旧的 base-owned 严格解析器会拒绝未知字段。必须先合入 parser、lifecycle、CLI/Schema adapter 与 hostile tests;待这些实现成为新的 merge-base authority 后,再用独立治理审批 PR 新增 pending,最后才由产品 PR 消费。 + +## 跨命令迁移原语 + +`approved-command-migrations-v1.json` 只接受两种 `kind`: + +| kind | CLI after 状态 | Schema 允许的精确投影 | +|---|---|---| +| `command_move` | legacy 命令仍 runnable、由 visible 变 hidden;replacement 由 absent 变 visible runnable | 同一 stable tool identity 的 `primary_cli_path` 改到 replacement;只允许清单列出的参数改名,参数类型、property、requiredness、default 等必须等价 | +| `flag_extraction` | legacy 命令保持 visible runnable;指定 legacy flag 仍可执行但由 visible 变 hidden;replacement 由 absent 变 visible runnable | source tool 只删除指定参数;replacement tool 必须位于精确的新路径,并保持 source 的 interface 与 safety identity;清单必须完整列出每个 source 参数到 replacement 参数或常量 property 的承接关系 | + +`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement +不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。 +稳定 Schema tool 可以继续接受普通的 optional 参数新增,但不得借路径迁移引入清单未登记的 +`required`、`cli_required` 或 `required_when` 参数;参数改名的目标也不得与历史 +Schema 中已有的其他参数重名,避免把两个历史参数静默合并。`flag_extraction` 只接受 +optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。它必须对 source tool +的全部历史参数逐项声明:普通参数使用精确 `from` → `to`(同名也必须显式写出),且恰好 +一个与 legacy flag 同名的 `from` 使用 `replacement_constant`,不得同时声明 `to`;所有 +`from` 与 replacement 参数/property 目标必须唯一。legacy bool flag 的 `no_opt` 必须等于 +常量布尔值的字符串形式。v1 只治理 optional bool flag 的 `NoOpt=true` 激活分支,因此 +`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` +必须通过 `NewLeafCommand` 的 `ConstParams` 声明并实际注入 +`convThreadEnabled=true`;手写 `RunE` 固定值、Cobra annotation 或只改清单都不能提供这份 +同源证据,Snapshot 只读取 `corecmd` 包内私有注册表公开的只读副本。第一次向旧快照增加 +bool 常量证据属于 bootstrap;一旦任一历史快照已记录该 +证据,普通 Interface Compare 会持续要求 property/value 集合完全一致,因此 ledger 清理后 +删除、翻转或增加常量仍会阻塞。若 candidate 改动 command ledger,则 +`internal/corecmd/corecmd.go`、`internal/corecmd/interface_const_params.go` 与 +`internal/helpers/leaf.go` 三份执行/证据桥必须保持 base Git blob 不变;框架演进必须先用 +独立 PR 合入,不能和产品消费混在一起。 + +replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run` 非空时不得删除或改值; +历史未声明时允许 replacement 新增 dry-run。这与普通 Schema 兼容规则保持同一单调边界。 + +两种迁移都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。 + +跨命令清单复用下文同一套 `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。 ## 两阶段迁移与回执清理 -每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期: +rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期: | 阶段 | PR 可以做什么 | 必须满足的快照状态 | |---|---|---| | 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 迁移前完全一致 | +| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`,canonical requiredness 保持不变;requiredness change 只把同名 flag 从 optional 提升为 required | | 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 核对: @@ -99,6 +164,27 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json 产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。 +同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批: + +```json +{ + "version": 1, + "migrations": [ + { + "kind": "requiredness_change", + "command": "dws report entry submit", + "flag": { + "name": "to-user-ids", + "before": {"present": true, "type": "string", "scope": "local"}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reject report submissions that have no visible recipient." + } + ] +} +``` + ## `alias_of` 是框架来源的受评审关系证据 `alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证: @@ -119,10 +205,11 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json ## 豁免边界 -一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding: +一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding: 1. legacy flag 的 `flag_became_hidden`(visible → hidden); 2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。 +3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。 以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖: @@ -130,6 +217,7 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json - flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移; - `alias_of` 缺失、指向变化或 alias chain; - 命令路径及任何无关的阻塞性接口变化; +- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移; - 不精确、部分完成、超出记录范围的 surface 变化。 ## Schema 投影边界 @@ -157,6 +245,12 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker 验证 candidate 是否仍与该 canonical contract 兼容。 +`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与 +`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter +不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、 +format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker, +任何不相干漂移继续阻塞。 + ## 本地验证 先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁: diff --git a/docs/command-index.md b/docs/command-index.md index a1cd90d8..a4b1d1e4 100644 --- a/docs/command-index.md +++ b/docs/command-index.md @@ -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. | diff --git a/docs/releasing.md b/docs/releasing.md index 428cdbcc..b6b57aa0 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -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 自动发布,不需要人工审批或输入确认短语。 ## 正式发布 @@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 `.changes/.md` 中增加一个独立 fragment;格式和允许的分类见 [`.changes/README.md`](../.changes/README.md)。预发封板时 `scripts/release/prepare-changelog.sh prerelease ` 会稳定排序并汇总所有未归档 -fragment,写入唯一版本章节后移动到 `.changes/released//`。因此并发 PR 不会争用 -`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。 +fragment,写入唯一版本章节后移动到 `.changes/released//`。如果 beta 发布后又有 +带 fragment 的 PR 合入,而维护者决定直接发布 stable, +`scripts/release/prepare-changelog.sh stable --from-beta ` 会保留 beta 晋级摘要 +模板,并把这些 post-beta fragments 写到明确的 `Changes since ` 边界之后,再移动到 +`.changes/released//`。没有 active fragment 时,stable 仍只生成原有晋级摘要 +模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与 +归档移动,供审计复核。 ## CI/CD 保证 - 只接受 `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`,不会覆盖稳定入口。 diff --git a/docs/rfc-skill-installation-and-upgrade.md b/docs/rfc-skill-installation-and-upgrade.md index a33bc95f..01db6595 100644 --- a/docs/rfc-skill-installation-and-upgrade.md +++ b/docs/rfc-skill-installation-and-upgrade.md @@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约 | 模式 | Agent 目录布局 | 选择方式 | |---|---|---| -| multi(默认) | `/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` | -| mono(兼容) | `/dws/` | `dws skill setup --mode mono` 或安装器的 mono opt-in | +| multi(默认) | canonical `~/.agents/skills/dingtalk-*/`;非 universal Agent 使用链接或复制兼容层 | 默认;`dws skill setup --mode multi` | +| mono(兼容) | canonical `~/.agents/skills/dws/`;非 universal Agent 使用链接或复制兼容层 | `dws skill setup --mode mono` 或安装器的 mono opt-in | 模式切换通过重新执行 setup 完成。安装 multi 前备份并移除 mono 的 `dws/`;安装 mono 前只备份并移除能够证明由 DWS 管理的 multi 目录。两个方向都不提供隐式、 @@ -140,10 +140,26 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen ## 8. Upgrade 与恢复语义 -升级器对每个 Agent 目标执行: +升级器始终先发布 `~/.agents/skills` canonical 集合。固定兼容注册表中被分类为 +universal 的 Agent 不再保留 Agent 私有副本;检测到的 +非 universal Agent(如 Claude、OpenClaw、Hermes、Windsurf)使用指向 canonical +的目录链接:npm 与 PowerShell 安装器在 Windows 上创建 junction,`dws upgrade` / +`dws skill setup` 创建符号链接(`os.Symlink`)。链接不可用时回退为内容完整的 +直接复制,包括未开启开发者模式、因而无法创建符号链接的 Windows。 +自定义 `CODEX_HOME`、`CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`AUTOHAND_HOME`、 +`GROK_HOME`、`VIBE_HOME`、`XDG_CONFIG_HOME` 与 OpenClaw 历史目录 `.clawdbot`、 +`.moltbot` 必须按 Agent 实际优先级解析。 -- 先探测具体 Agent home;只在没有任何具体 Agent 时使用 `~/.agents/skills` 通用 fallback; -- 具体 Agent 安装成功后,将 `~/.agents/skills` 中旧的 DWS 受管副本可恢复地迁入备份,避免 Codex 等同时扫描两个根目录时重复发现同名 Skill; +Agent 兼容矩阵以 `vercel-labs/skills` 的 `agents.ts` 与 `installer.ts`(基准提交 +`c6f69c6`)为契约:76 个 ID 必须完整登记,其中 19 个 universal、57 个 +non-universal。`eve`、`promptscript` 没有全局目录,因此全局安装时跳过;多个 Agent +解析到同一个 XDG 目录时按最终绝对路径去重(Windows 大小写不敏感)。DWS 额外支持 +Qoderwork(按 non-universal Agent 建立兼容链接);旧版使用的 `.github/skills`、 +`.amp/skills`、`.cline/skills` 与 +`.windsurf/skills` 仅作为可恢复迁移清理目标,不计入上游 Agent 枚举。 +对 universal Agent,上游 installer 的 global 模式明确选择 canonical 并跳过 +Agent 私有 global 目录;注册表中的 `globalSkillsDir` 仍用于识别和退役历史 native +路径,不作为 universal symlink 模式的发布目标。 1. 只读计算对面布局、过期受管 Skill 和同名官方 Skill; 2. 在目标文件系统的 staging 中复制完整新集合; @@ -151,6 +167,14 @@ Agent 仍只需以 `SKILL.md` 发现和加载 Skill;统一元数据位于 Agen 4. 逐项发布 staging;任一发布失败时删除已发布的新目录,并逆序恢复该目标的全部旧目录; 5. 仅在没有目标失败且至少一个目标成功时更新状态快照。 +旧集合可能位于外部卷或自定义 Agent 根,而备份固定写入 +`~/.dws/skill-backups`。因此备份与反向恢复统一采用 rename-first:同卷直接原子 +rename;遇到跨文件系统错误时,在目标所在文件系统创建临时 staging,词法复制并 +保留目录/文件权限、普通文件、符号链接及 dangling symlink,校验路径类型、目录项、 +文件大小与 SHA256、链接目标后,再将 staging 原子 rename 为正式目标。正式目标 +再次校验成功后才删除源路径。复制、校验或发布失败时保留源并清理 staging;源删除 +失败时允许源与正式目标同时存在,但必须返回明确错误,不能报告成功。 + Go upgrade 当前提供 **单 Agent 目标级事务恢复**:复制失败发生在旧目录移动前; 备份中途失败会恢复此前已移动的目录;发布中途失败会恢复该目标的完整旧集合。不同 Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功升级的其他目标,这与 @@ -159,11 +183,31 @@ Agent 目标仍彼此独立,一个目标失败不会回滚此前已经成功 ## 9. 备份合同 - 路径:`~/.dws/skill-backups//...`; -- 主要操作:同一文件系统内使用 rename 移动; +- 主要操作:同一文件系统内使用 rename 移动;跨文件系统使用目标卷 staging 的 + copy → verify → publish → remove 回退; - 失败语义:备份失败时原目录保持不变,目标安装失败; +- 恢复语义:反向恢复使用相同回退;若删除备份源失败,原路径和备份可同时存在, + 但恢复必须失败并明确提示两份均被保留; - 可见性:计划和执行日志显示原路径与备份路径; - 保留策略:自动修剪,仅保留最近 5 批。 +跨卷回退只有 staging → 正式目标的发布 rename 是原子的,整次迁移不是跨文件系统 +原子事务;该边界由“发布前不删源、发布后再次校验、删除失败保留两份”补偿。Shell +入口继续使用系统 `mv` 的跨文件系统复制/删除能力;Go、npm 与 PowerShell 显式实现 +上述验证和失败合同。 + +原子 no-replace 发布(Linux `RENAME_NOREPLACE`、Darwin `RENAME_EXCL`)依赖底层文件 +系统支持:`rename(2)` 只列出 ext4、btrfs、tmpfs 与 cifs,因此 NFS、FUSE 与 +overlayfs 家目录会以 `EINVAL` 拒绝该 flag。这些文件系统不得让安装整体失败,而是降级 +为原子占位发布:目录目标用 `mkdir` 认领(已占用即 `EEXIST`,认领期间目标始终被本事务 +持有,源子项逐个移入认领目录,最终以 rename 覆盖仅属于本事务的空认领或直接移入); +普通文件目标用硬链接占位(同样以 `EEXIST` 拒绝已占用路径)后删除源。任何一步失败都会 +回迁已移动的子项并只撤销本事务的占位,被并发创建的对象(文件、符号链接或目录)既不会 +被覆盖,也不会被链接进内部。逐子项移动路径不是全量原子可见(降级文件系统上的可接受 +边界),但不覆盖契约在所有平台保持不变。Windows `MoveFile` 本身即拒绝已存在的目标, +无需降级。npm 与 Shell 安装面遵循同一占位模型:目录用 `mkdir`/子项移动,链接直接在 +目标路径创建(symlink(2) 原子拒绝已占用路径)。 + 备份是安装安全机制,不等于独立 rollback 产品。需要切回 mono 时重新运行 `dws skill setup --mode mono`。 @@ -192,6 +236,7 @@ setup 在未显式指定 `--source` 时的本地回退缓存。 | `scripts/install.ps1` | multi | 任一检测到的目标失败则脚本非零 | | `scripts/install-skills.sh` | multi | 任一检测到的目标失败则脚本非零 | | npm `install.js` | multi | 任一检测到的目标失败则 postinstall 失败 | +| `scripts/install-event.sh` / `install-devapp.*` | 产品 multi 子集 | 同样使用 canonical 与 Agent 兼容层 | Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行相同流程。 @@ -209,6 +254,12 @@ Homebrew 不直接向 Agent home 铺设 Skill;安装 CLI 后由 setup 执行 - 复制失败不留下 Agent 可见的残缺官方目录; - 普通 upgrade 恢复被删除的预制 Skill,并安装新增官方 Skill; - Windows、macOS、Linux 的路径和覆盖率门禁; +- symlinked parent、npm/PowerShell 的 Windows junction、`dws upgrade` / + `dws skill setup` 的符号链接、链接失败复制回退与 broken link 修复; +- Claude/Codex/Hermes 自定义根目录及 OpenClaw 历史目录优先级; +- `CLAUDE_CONFIG_DIR`、`HERMES_HOME`、`XDG_CONFIG_HOME` 等自定义根跨文件系统时的 + 正向备份、反向恢复、普通链接及 dangling symlink 词法保留; +- copy、verify、publish、remove 各阶段故障,以及非跨设备权限错误不得进入复制回退; - npm、Shell、PowerShell 与包管理器安装冒烟。 ## 13. 后续演进 diff --git a/docs/shortcut-downstream-requirements/attendance.md b/docs/shortcut-downstream-requirements/attendance.md new file mode 100644 index 00000000..064abb6b --- /dev/null +++ b/docs/shortcut-downstream-requirements/attendance.md @@ -0,0 +1,310 @@ +# Attendance Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:Lark CLI 1.0.87 +> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 + +## 1. 执行摘要 + +- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。 +- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。 +- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。 +- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。 +- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` | +| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` | +| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 | +| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut | +| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut | +| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` | +| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` | +| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` | +| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable | +| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable | +| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 | +| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable | +| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog | +| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable | +| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 | +| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog | +| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 | + +## 3. 下游需求明细 + +### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取 + +#### A. 用户任务与现状 + +- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。 +- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。 +- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。 +- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。 +- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。 +- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。 +- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。 +- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。 +- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。 +- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。 +- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。 + +#### C. 验收标准 + +1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。 +2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。 +3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。 +4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。 + +#### D. 临时处置 + +`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。 + +### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同 + +#### A. 用户任务与现状 + +- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。 +- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。 +- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。 +- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。 +- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。 +- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。 +- 安全证据句柄:`ATT-REPORT-NULL-01`。 + +#### B. 需要下游提供的合同 + +- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。 +- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。 +- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。 +- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。 +- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。 +- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。 + +#### C. 验收标准 + +1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。 +2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。 +3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。 +4. 假期报表至少覆盖已知非空、合法空和未知假期类型。 +5. 分页/partial 分支和远端零残留通过。 + +#### D. 临时处置 + +三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。 + +### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据 + +#### A. 用户任务与现状 + +- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。 +- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。 +- 安全证据句柄:`ATT-CHECK-PAGE-01`。 + +#### B. 需要下游提供的合同 + +- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。 +- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。 +- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。 +- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。 + +#### C. 验收标准与临时处置 + +- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。 +- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。 + +### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源 + +#### A. 用户任务与现状 + +- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。 +- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。 +- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。 +- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。 +- 安全证据句柄:`ATT-FIXTURE-GAP-01`。 + +#### B. 需要的测试基础设施与合同 + +- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。 +- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。 +- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。 +- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。 +- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。 + +#### C. 验收标准与临时处置 + +1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。 +2. create→get→update→restore/delete 覆盖班次、考勤组与排班。 +3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。 +4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。 +5. 测试结束远端和本地均零残留。 + +在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。 + +### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture + +#### A. 用户任务与现状 + +- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。 +- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。 +- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。 +- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。 +- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。 + +#### B. 需要下游提供的合同与 fixture + +- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。 +- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。 +- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。 +- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。 + +#### C. 验收标准与临时处置 + +1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。 +2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。 +3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。 + +在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。 + +### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份 + +#### A. 用户任务与现状 + +- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。 +- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。 +- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。 +- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。 +- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。 + +#### B. 需要下游提供的合同 + +- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。 +- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。 +- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。 +- 改动需 additive/versioned;现有详情业务字段保持兼容。 + +#### C. 验收标准与临时处置 + +1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。 +2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。 +3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。 + +下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。 + +### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture + +#### A. 用户任务与现状 + +- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。 +- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。 +- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。 +- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。 + +#### B. 需要下游提供的合同与 fixture + +- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。 +- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。 +- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。 +- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。 + +#### C. 验收标准与临时处置 + +1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。 +2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。 +3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。 +4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。 + +能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。 + +### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误 + +#### A. 用户任务与现状 + +- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。 +- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。 +- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。 +- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。 +- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。 + +#### B. 需要下游提供的合同 + +- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。 +- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。 +- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。 +- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。 + +#### C. 验收标准与临时处置 + +1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。 +2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。 +3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。 +4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。 + +下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---|---| +| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 | + +无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。 + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---|---| +| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 | +| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 | +| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 | +| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 | +| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 | +| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 | +| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 | +| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 | +| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS | +| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS | + +### 6.1 clean-HEAD live 发布门状态 + +| 叶子 | clean executable HEAD 双层证据 | 发布状态 | +|---|---|---| +| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 | +| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 | +| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 | +| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS | +| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS | + +pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。 + +上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。 + +## 7. 安全与脱敏声明 + +- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。 +- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。 +- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。 +- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-downstream-requirements/mail.md b/docs/shortcut-downstream-requirements/mail.md new file mode 100644 index 00000000..a9fede56 --- /dev/null +++ b/docs/shortcut-downstream-requirements/mail.md @@ -0,0 +1,198 @@ +# Mail Shortcut 下游业务能力需求规格 + +> 日期:2026-08-18 +> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中 +> 对比基线:lark-cli 1.0.87 +> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。 +> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。 + +## 1. 执行摘要 + +本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。 + +上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。 + +仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。 + +| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut | +|---|---|---|---|---|---|---| +| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` | +| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` | +| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` | +| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` | +| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` | +| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` | +| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` | +| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut | +| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut | +| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` | + +## 2. 用户任务与能力缺口总览 + +| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 | +|---|---|---|---|---|---| +| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 | +| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable | +| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 | +| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable | +| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable | +| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 | +| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 | +| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 | +| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 | +| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 | +| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut | +| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 | +| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut | +| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable | + +## 3. 下游需求明细 + +### `DS-Mail-001` — 可验证的发送生命周期 + +- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。 +- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。 +- 所需接口合同: + - 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。 + - 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。 + - 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。 + - 支持幂等键,或明确 unknown commit 不可自动重试。 + - 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。 +- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。 + +### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真 + +- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。 +- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。 +- 所需接口合同: + - 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。 + - 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。 + - 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。 +- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。 + +### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态 + +- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。 +- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。 +- 所需接口合同: + - 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。 + - 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。 + - 软删除和永久删除使用不同操作,危险级别与确认要求可声明。 + - 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。 +- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。 + +### `DS-Mail-004` — 已读回执与签名 + +- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。 +- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。 +- 所需接口合同: + - 消息详情公开稳定回执请求状态和请求者身份类型。 + - 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。 + - 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。 +- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。 + +### `DS-Mail-005` — 新邮件监听的订阅生命周期 + +- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。 +- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。 +- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。 +- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。 + +### `DS-Mail-006` — 模板附件与内联图片事务 + +- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。 +- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。 +- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。 +- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。 + +### `DS-Mail-007` — 联系人写操作的稳定身份 + +- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。 +- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。 +- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。 +- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。 + +### `DS-Mail-008` — 统一成功、空结果与分页协议 + +- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。 +- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。 +- 所需接口合同: + - success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。 + - 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。 + - 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。 + - 保留兼容期,但提供 capability/version 让上游安全切换。 +- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。 + +### `DS-Mail-009` — 安全租户与真实 E2E fixture + +- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。 +- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。 +- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。 +- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。 + +### `DS-Mail-010` — 草稿/模板可证明的清理终态 + +- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。 +- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。 +- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。 +- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。 +- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。 + +## 4. Lark 对齐与平台差异 + +| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 | +|---|---|---|---| +| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 | +| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable | +| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable | +| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut | +| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut | +| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut | +| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable | +| `+signature` | no | 缺签名读取资源 | platform unavailable | +| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked | +| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw | +| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed | + +## 5. 超越 Lark 的产品机会 + +| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 | +|---|---|---|---| +| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 | +| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 | +| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 | +| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 | +| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 | + +## 6. 无需下游变更的上游修复 + +| Shortcut | 上游根因 | 已完成修复 | 回归证据 | +|---|---|---|---| +| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 | +| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 | +| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 | +| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 | +| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` | +| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS | +| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 | +| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED | + +### 6.1 clean executable HEAD 双层证据 + +| 公开入口 | exact Shortcut + owning raw 证据 | 状态 | +|---|---|---| +| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 | +| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 | +| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 | + +8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。 + +## 7. 安全与脱敏声明 + +- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。 +- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。 +- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。 +- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。 +- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。 +- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。 diff --git a/docs/shortcut-public-catalog.json b/docs/shortcut-public-catalog.json index 30bfa1d6..6e6904cb 100644 --- a/docs/shortcut-public-catalog.json +++ b/docs/shortcut-public-catalog.json @@ -1,6 +1,6 @@ { - "generated_at": "2026-08-18T17:38:50.904696", - "count": 436, + "generated_at": "2026-08-20T11:51:44.156046", + "count": 416, "results": [ { "suite": "semantic", @@ -933,137 +933,84 @@ "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-record", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+check-result", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-adjustment-rule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-approve-template", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-checkin-record", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-leave-records", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+get-overtime-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "schema_leaf", + "semantic_delta": "使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+get-schedule", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-self-setting", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+get-summary", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+list-approve", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+list-leave-types", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+my-attendance", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+query-report-data", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-adjustment-rule", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-class", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。", + "availability": "available" }, { - "suite": "read", - "service": "attendance", - "command": "+search-group", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "attendance", "command": "+search-overtime-rule", "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "attendance", - "command": "+this-month", - "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。", + "availability": "available" }, { "suite": "semantic", @@ -2547,32 +2494,14 @@ "status": "real-ok" }, { - "suite": "read", - "service": "ding", - "command": "+list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+recall-personal", - "risk": "high-risk-write", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "ding", "command": "+receiver-status", "risk": "read", - "status": "real-ok" - }, - { - "suite": "write", - "service": "ding", - "command": "+send-personal", - "risk": "write", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", + "availability": "available" }, { "suite": "semantic", @@ -3305,74 +3234,84 @@ "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+contact-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+find-mail-user", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+folder-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+recent-mail", + "command": "+message", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", + "service": "mail", + "command": "+messages", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。", + "availability": "available" + }, + { + "suite": "semantic", "service": "mail", "command": "+search-mail", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+tag-list", + "command": "+thread", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "mail", - "command": "+template-list", + "command": "+triage", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。", + "availability": "available" }, { - "suite": "read", - "service": "mail", - "command": "+thread-list", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "mail", - "command": "+unread-mail", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "mail", "command": "+user-search", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。", + "availability": "available" }, { "suite": "semantic", @@ -3645,67 +3584,54 @@ "availability": "available" }, { - "suite": "read", - "service": "oa", - "command": "+list-cc", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-executed", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-forms", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-pending", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+list-submitted", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", - "service": "oa", - "command": "+my-initiated", - "risk": "read", - "status": "real-ok" - }, - { - "suite": "read", + "suite": "semantic", "service": "oa", "command": "+search-forms", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+inbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "availability": "available" }, { - "suite": "read", + "suite": "semantic", "service": "report", "command": "+outbox-list", "risk": "read", - "status": "real-ok" + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+report-latest", + "risk": "read", + "status": "reviewed_available", + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", + "availability": "available" + }, + { + "suite": "semantic", + "service": "report", + "command": "+template-search", + "risk": "read", + "status": "reviewed_available", + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", + "availability": "available" }, { "suite": "read", diff --git a/internal/app/command_path_fallback_e2e_test.go b/internal/app/command_path_fallback_e2e_test.go index 1532bd08..db8e6133 100644 --- a/internal/app/command_path_fallback_e2e_test.go +++ b/internal/app/command_path_fallback_e2e_test.go @@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t {path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}}, {path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}}, {path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}}, - {path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}}, + {path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}}, } for _, test := range tests { t.Run(test.path, func(t *testing.T) { diff --git a/internal/app/event_command.go b/internal/app/event_command.go index f0cc1e24..ef727259 100644 --- a/internal/app/event_command.go +++ b/internal/app/event_command.go @@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop --dry-run 预览 Selection: contract.SelectionSpec{ AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON", UseWhen: []string{ - "需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件", + "需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件", "需要监听指定群的标题变更、成员进退群或群解散事件", "用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制", }, diff --git a/internal/app/event_personal_attempts_test.go b/internal/app/event_personal_attempts_test.go index 1acdea27..913086bb 100644 --- a/internal/app/event_personal_attempts_test.go +++ b/internal/app/event_personal_attempts_test.go @@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent } } - if publicCount != 22 { - t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount) + if publicCount != 23 { + t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount) } for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} { if !ruleTypes[ruleType] { diff --git a/internal/app/event_personal_oa_test.go b/internal/app/event_personal_oa_test.go index 11f8dbe4..9defa152 100644 --- a/internal/app/event_personal_oa_test.go +++ b/internal/app/event_personal_oa_test.go @@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) { "process_code", "title", "status", "create_time", "event_time", }, }, + { + eventKey: personal.EventOAApprovalInstanceCC, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, { eventKey: personal.EventOAApprovalInstanceTerminated, properties: []string{ @@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) { personal.EventOAApprovalTaskFinished, personal.EventOAApprovalTaskRedirected, personal.EventOAApprovalInstanceStarted, + personal.EventOAApprovalInstanceCC, personal.EventOAApprovalInstanceTerminated, personal.EventOAApprovalInstanceFinished, } @@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t * personal.EventOAApprovalTaskFinished, personal.EventOAApprovalTaskRedirected, personal.EventOAApprovalInstanceStarted, + personal.EventOAApprovalInstanceCC, personal.EventOAApprovalInstanceTerminated, personal.EventOAApprovalInstanceFinished, } diff --git a/internal/app/oa_shortcut_contract_test.go b/internal/app/oa_shortcut_contract_test.go new file mode 100644 index 00000000..55b9f703 --- /dev/null +++ b/internal/app/oa_shortcut_contract_test.go @@ -0,0 +1,35 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package app + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" +) + +func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + for _, canonical := range []string{ + "oa.shortcut_approve_by", + "oa.shortcut_done_approvals", + "oa.shortcut_list_cc", + "oa.shortcut_list_executed", + "oa.shortcut_list_forms", + "oa.shortcut_list_pending", + "oa.shortcut_list_submitted", + "oa.shortcut_my_initiated", + "oa.shortcut_pending", + "oa.shortcut_search_forms", + } { + tool, ok := snapshot.Tools[canonical] + if !ok { + t.Errorf("final Schema lacks OA tool %s", canonical) + continue + } + if got := tool["availability"]; got != contract.InterfaceAvailable { + t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable) + } + } +} diff --git a/internal/app/param_alias_e2e_test.go b/internal/app/param_alias_e2e_test.go index fed2527b..6316f78c 100644 --- a/internal/app/param_alias_e2e_test.go +++ b/internal/app/param_alias_e2e_test.go @@ -51,7 +51,66 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string { switch tool { case "list_calendar_events": - return `{"result":{"events":[]}}` + return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}` + case "get_calendar_detail": + return c.paramAliasCalendarDetailResponse() + case "get_calendar_participants": + return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}` + case "search_calendar": + return `{"success":true,"result":{"calendars":[]}}` + case "search_rooms": + return `{"success":true,"result":{"rooms":[]}}` + case "query_available_meeting_room": + return `{"success":true,"result":{"rooms":[],"hasMore":false}}` + case "list_meeting_room_groups": + return `{"success":true,"result":{"groups":[]}}` + case "query_busy_status": + 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": + return `{"success":true}` + case "respond": + status := "accepted" + if call := c.lastParamAliasCall(); call != nil { + if value, ok := call.args["responseStatus"].(string); ok && value != "" { + status = value + } + } + encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}}) + return string(encoded) + case "get_current_user_profile": + return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}` case "query_records": return `{"success":true,"status":"success","error":{},"data":{}}` case "search_mail_users": @@ -127,6 +186,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string } } +func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall { + if len(c.calls) == 0 { + return nil + } + return &c.calls[len(c.calls)-1] +} + +func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string { + event := map[string]any{ + "eventId": "event-1", + "summary": "Fixture Meeting", + "description": "fixture description", + "startDateTime": "2026-03-10T09:00:00+08:00", + "endDateTime": "2026-03-10T10:00:00+08:00", + } + for _, call := range c.calls { + switch call.tool { + case "create_calendar_event", "update_calendar_event": + for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} { + if value, ok := call.args[key]; ok { + event[key] = value + } + } + case "respond": + if value, ok := call.args["responseStatus"]; ok { + event["responseStatus"] = value + } + case "delete_calendar_event": + event["status"] = "cancelled" + } + } + encoded, _ := json.Marshal(map[string]any{"success": true, "result": event}) + return string(encoded) +} + func (*paramAliasCaptureCaller) Format() string { return "json" } func (*paramAliasCaptureCaller) DryRun() bool { return false } func (*paramAliasCaptureCaller) Fields() string { return "" } diff --git a/internal/app/param_alias_payload_equivalence_test.go b/internal/app/param_alias_payload_equivalence_test.go index 6d83f718..cfd54910 100644 --- a/internal/app/param_alias_payload_equivalence_test.go +++ b/internal/app/param_alias_payload_equivalence_test.go @@ -5,6 +5,8 @@ package app import ( "errors" + "os" + "os/exec" "reflect" "strings" "testing" @@ -17,6 +19,8 @@ import ( const ( appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE" appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE" + + paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD" ) // paramAliasCompleteCommands is deliberately keyed by the exact reviewed @@ -46,7 +50,37 @@ var paramAliasCompleteCommands = map[string][]string{ "aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"}, "attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, "attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"}, + "calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"}, + "calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"}, + "calendar +book-search": {"calendar", "+book-search", "--query", "fixture"}, + "calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"}, + "calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"}, + "calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"}, + "calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"}, + "calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"}, + "calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"}, + "calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"}, + "calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"}, + "calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"}, + "calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"}, + "calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"}, + "calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, + "calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"}, + "calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"}, + "calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"}, "calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"}, + "calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"}, + "calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"}, + "calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"}, + "calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"}, + "calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"}, + "calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"}, "chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"}, "chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"}, "chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"}, @@ -192,12 +226,72 @@ var paramAliasCompleteCommands = map[string][]string{ "mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"}, "mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"}, "mail user search": {"mail", "user", "search", "--keyword", "fixture"}, - "oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"}, "oa +search-forms": {"oa", "+search-forms", "--query", "fixture"}, "oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"}, "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. @@ -497,6 +591,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 @@ -566,6 +674,132 @@ 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 +// compares the final captured transport calls; the owning top-level test runs +// these allocations in a short-lived race-instrumented subprocess so all Root +// registrations are released together when that process exits. +var paramAliasCalendarPayloadCases = map[string]bool{ + paramAliasPayloadCaseKey("calendar +agenda", "from"): true, + paramAliasPayloadCaseKey("calendar +agenda", "to"): true, + paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true, + paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true, + paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +book", "summary"): true, + paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true, + paramAliasPayloadCaseKey("calendar +book-search", "search"): true, + paramAliasPayloadCaseKey("calendar +book-search", "name"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true, + paramAliasPayloadCaseKey("calendar +free", "name"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true, + paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true, + paramAliasPayloadCaseKey("calendar +my-free", "from"): true, + paramAliasPayloadCaseKey("calendar +my-free", "to"): true, + paramAliasPayloadCaseKey("calendar +invite", "id"): true, + paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "id"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "to"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-search", "query"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true, + paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true, + paramAliasPayloadCaseKey("calendar busy search", "room-id"): true, + paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true, + paramAliasPayloadCaseKey("calendar event create", "tz"): true, + paramAliasPayloadCaseKey("calendar event create", "room-id"): true, + paramAliasPayloadCaseKey("calendar event respond", "response-status"): true, + paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar event update", "tz"): true, + paramAliasPayloadCaseKey("calendar room add", "room-id"): true, + paramAliasPayloadCaseKey("calendar room delete", "room-id"): true, + paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +create", "summary"): true, + paramAliasPayloadCaseKey("calendar +create", "description"): true, + paramAliasPayloadCaseKey("calendar +create", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-ids"): true, + paramAliasPayloadCaseKey("calendar +create", "room-id"): true, + paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +create", "to"): true, + paramAliasPayloadCaseKey("calendar +create", "from"): true, + paramAliasPayloadCaseKey("calendar +get", "event-id"): true, + paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "from"): true, + paramAliasPayloadCaseKey("calendar +room-find", "to"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true, + paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true, + paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true, + paramAliasPayloadCaseKey("calendar +room-find", "query"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, + paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true, + paramAliasPayloadCaseKey("calendar +search-event", "from"): true, + paramAliasPayloadCaseKey("calendar +search-event", "to"): true, + paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true, + paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "from"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "to"): true, + paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true, + paramAliasPayloadCaseKey("calendar +update", "event-id"): true, + paramAliasPayloadCaseKey("calendar +update", "from"): true, + paramAliasPayloadCaseKey("calendar +update", "summary"): true, + paramAliasPayloadCaseKey("calendar +update", "description"): true, + paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true, + paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true, +} + +// paramAliasCalendarConfirmationCases selects one newly reviewed alias for +// every Calendar Shortcut whose runtime contract requires user confirmation. +// The complete Calendar matrix proves confirmed canonical/alias payload +// equality; these representatives additionally prove semantic normalization +// cannot cross the confirmation boundary before the first transport call. +var paramAliasCalendarConfirmationCases = map[string]bool{ + paramAliasPayloadCaseKey("calendar +book", "summary"): true, + paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true, + paramAliasPayloadCaseKey("calendar +create", "summary"): true, + paramAliasPayloadCaseKey("calendar +invite", "id"): true, + paramAliasPayloadCaseKey("calendar +reschedule", "from"): true, + paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true, + paramAliasPayloadCaseKey("calendar +update", "event-id"): true, +} + func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) { concepts, err := cli.LoadParamConcepts() if err != nil { @@ -573,6 +807,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 { @@ -581,6 +816,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) @@ -593,60 +830,167 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres continue } - caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) - if !paramAliasRepresentativePayloadCases[caseKey] { + if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] { continue } executedRepresentatives[caseKey] = true t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { - - canonicalCaller := ¶mAliasCaptureCaller{} - _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) - if canonicalErr != nil { - t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) - } - if len(canonicalCaller.calls) == 0 { - t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) - } - - aliasCaller := ¶mAliasCaptureCaller{} - ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) - if aliasErr != nil { - t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) - } - if ctx == nil { - t.Fatal("complete alias command skipped PreParse") - } - normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller) - if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { - t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) - } + assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs) }) } 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) + } +} + +func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) { + if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" { + command := exec.Command( + os.Args[0], + "-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$", + "-test.count=1", + "-test.timeout=5m", + ) + command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1") + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output))) + } + return + } + + concepts, err := cli.LoadParamConcepts() + if err != nil { + t.Fatalf("LoadParamConcepts() error = %v", err) + } + + executed := make(map[string]bool) + executedConfirmation := make(map[string]bool) + for _, fixture := range concepts.Fixture { + caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted) + if !paramAliasCalendarPayloadCases[caseKey] { + continue + } + executed[caseKey] = true + fixture := fixture + t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) { + complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect) + if !ok { + t.Fatal("reviewed Calendar alias has no complete-command E2E template") + } + canonicalArgs := append([]string(nil), complete...) + aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted) + if replacements != 1 { + t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs) + } + assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs) + if paramAliasCalendarConfirmationCases[caseKey] { + executedConfirmation[caseKey] = true + assertParamAliasCannotBypassConfirmation(t, aliasArgs) + } + }) + } + + for caseKey := range paramAliasCalendarPayloadCases { + if !executed[caseKey] { + t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey) + } + } + if len(executed) != len(paramAliasCalendarPayloadCases) { + t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases)) + } + for caseKey := range paramAliasCalendarConfirmationCases { + if !executedConfirmation[caseKey] { + t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey) + } + } + if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) { + t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases)) + } +} + +func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) { + t.Helper() + unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes") + if removals != 1 { + t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs) + } + + caller := ¶mAliasCaptureCaller{} + ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...) + if ctx == nil { + t.Fatal("unconfirmed Calendar alias command skipped PreParse") + } + var appErr *apperrors.Error + if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs) + } + if len(caller.calls) != 0 { + t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls) + } +} + +func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) { + t.Helper() + canonicalCaller := ¶mAliasCaptureCaller{} + _, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...) + if canonicalErr != nil { + t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls) + } + if len(canonicalCaller.calls) == 0 { + t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs) + } + + aliasCaller := ¶mAliasCaptureCaller{} + ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...) + if aliasErr != nil { + t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls) + } + if ctx == nil { + t.Fatal("complete alias command skipped PreParse") + } + normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller) + if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) { + t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls) } } @@ -864,7 +1208,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) @@ -946,6 +1303,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) { return variant, true } } + if ok { + return complete, true + } + complete, ok = paramAliasCandidateCompleteCommands[command] return complete, ok } diff --git a/internal/app/schema_agent_examples_dry_run_test.go b/internal/app/schema_agent_examples_dry_run_test.go index a3b8906f..6458979e 100644 --- a/internal/app/schema_agent_examples_dry_run_test.go +++ b/internal/app/schema_agent_examples_dry_run_test.go @@ -274,6 +274,7 @@ type agentExampleFiles struct { markdown string json string batch string + job string binary string image string } @@ -283,12 +284,14 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown := filepath.Join(root, "content.md") jsonFile := filepath.Join(root, "report.json") batch := filepath.Join(root, "styles.json") + job := filepath.Join(root, "job.json") binary := filepath.Join(root, "report.pdf") image := filepath.Join(root, "chart.png") for path, content := range map[string][]byte{ markdown: []byte("# Agent dry-run fixture\n\nNo business call is allowed.\n"), jsonFile: []byte(`[{"content":"Agent dry-run fixture","sort":"0","key":"fixture","contentType":"markdown","type":"1"}]`), batch: []byte(`[{"sheetId":"Sheet1","range":"A1:B2","fontWeight":"bold"}]`), + job: []byte(`{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"minSalary":20000,"maxSalary":35000,"extData":{"headCount":1,"fullTimeExtData":{"salaryMonth":12}},"creatorUserId":"creator-user-id","ownerUserIds":["owner-user-id"]}`), binary: []byte("%PDF-1.4\n%%EOF\n"), image: {0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n'}, } { @@ -301,6 +304,7 @@ func newAgentExampleFiles(t testing.TB, root string) agentExampleFiles { markdown: "./" + filepath.Base(markdown), json: "./" + filepath.Base(jsonFile), batch: "./" + filepath.Base(batch), + job: "./" + filepath.Base(job), binary: "./" + filepath.Base(binary), image: "./" + filepath.Base(image), } @@ -351,6 +355,10 @@ func materializeAgentExampleArgv(argv []string, files agentExampleFiles) []strin replacement = files.markdown case "contents-file": replacement = files.json + case "from": + if strings.HasSuffix(strings.ToLower(value), "job.json") { + replacement = files.job + } case "batch": if strings.HasSuffix(strings.ToLower(value), "styles.json") { replacement = files.batch diff --git a/internal/app/schema_completeness_test.go b/internal/app/schema_completeness_test.go index 6346b0fd..a412c778 100644 --- a/internal/app/schema_completeness_test.go +++ b/internal/app/schema_completeness_test.go @@ -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", diff --git a/internal/app/schema_oa_attachment_contract_test.go b/internal/app/schema_oa_attachment_contract_test.go index 75d40694..33a0f359 100644 --- a/internal/app/schema_oa_attachment_contract_test.go +++ b/internal/app/schema_oa_attachment_contract_test.go @@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t } } +// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的 +// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT, +// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。 +func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) { + snapshot := fullSchemaSnapshotForTest(t) + tool := snapshot.Tools["oa.attachment_upload"] + if tool == nil { + t.Fatal("oa.attachment_upload is missing from final Schema") + } + if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" { + t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got) + } + if got := schemaContractString(tool["interface_mode"]); got != "composite" { + t.Fatalf("interface_mode = %q, want composite", got) + } + if got := schemaContractString(tool["availability"]); got != "available" { + t.Fatalf("availability = %q, want available", got) + } + if got := schemaContractString(tool["interface_reason"]); got == "" { + t.Fatal("composite upload command must document an interface reason") + } + if got := schemaContractString(tool["effect"]); got != "write" { + t.Fatalf("effect = %q, want write", got) + } + if got := schemaContractString(tool["risk"]); got != "low" { + t.Fatalf("risk = %q, want low", got) + } + if got := schemaContractString(tool["confirmation"]); got != "not_required" { + t.Fatalf("confirmation = %q, want not_required", got) + } + parameters := schemaContractMap(tool["parameters"]) + for _, flag := range []string{"file", "file-name", "md5"} { + if parameters[flag] == nil { + t.Fatalf("upload --%s is missing from final Schema", flag) + } + } + if required, _ := parameters["file"]["required"].(bool); !required { + t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"]) + } + result := schemaContractMap(tool["result"]) + dataSchema := schemaContractMap(result["data_schema"]) + properties := schemaContractMap(dataSchema["properties"]) + if properties["fileId"] == nil { + t.Fatal("upload Result data_schema is missing fileId") + } +} + func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any { t.Helper() result, ok := tool["result"].(map[string]any) diff --git a/internal/app/schema_recruit_contract_test.go b/internal/app/schema_recruit_contract_test.go new file mode 100644 index 00000000..ece8ceb9 --- /dev/null +++ b/internal/app/schema_recruit_contract_test.go @@ -0,0 +1,74 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package app + +import ( + "bytes" + "encoding/json" + "reflect" + "testing" +) + +func recruitSchemaLeaf(t *testing.T, canonical string, compact bool) map[string]any { + t.Helper() + root := NewRootCommand() + var stdout, stderr bytes.Buffer + root.SetOut(&stdout) + root.SetErr(&stderr) + args := []string{"schema", canonical, "--format", "json"} + if compact { + args = append(args, "--compact") + } + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatalf("execute schema %s compact=%v: %v; stderr=%s", canonical, compact, err, stderr.String()) + } + var payload map[string]any + if err := json.Unmarshal(stdout.Bytes(), &payload); err != nil { + t.Fatalf("decode schema %s compact=%v: %v", canonical, compact, err) + } + return payload +} + +func TestRecruitDeliveredSchemaPublishesResultAndPagination(t *testing.T) { + for _, canonical := range []string{"recruit.list_jobs", "recruit.get_job_detail", "recruit.create_job"} { + t.Run(canonical, func(t *testing.T) { + full := recruitSchemaLeaf(t, canonical, false) + compact := recruitSchemaLeaf(t, canonical, true) + if full["result"] == nil || compact["result"] == nil { + t.Fatalf("result missing: full=%#v compact=%#v", full["result"], compact["result"]) + } + if !reflect.DeepEqual(full["result"], compact["result"]) { + t.Fatalf("full/compact result mismatch\nfull=%#v\ncompact=%#v", full["result"], compact["result"]) + } + if canonical == "recruit.list_jobs" { + if full["pagination"] == nil || compact["pagination"] == nil { + t.Fatalf("list pagination missing: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + if !reflect.DeepEqual(full["pagination"], compact["pagination"]) { + t.Fatalf("full/compact pagination mismatch\nfull=%#v\ncompact=%#v", full["pagination"], compact["pagination"]) + } + parameters, _ := full["parameters"].(map[string]any) + cursor, _ := parameters["cursor"].(map[string]any) + if cursor["type"] != "string" || cursor["interface_type"] != "number" { + t.Fatalf("cursor contract = %#v, want CLI string converted to MCP number", cursor) + } + size, _ := parameters["size"].(map[string]any) + if required, _ := size["required"].(bool); required { + t.Fatalf("size required = true, want false: %#v", size) + } + if size["default"] != "20" { + t.Fatalf("size default = %#v, want 20", size["default"]) + } + compactParameters, _ := compact["parameters"].(map[string]any) + compactSize, _ := compactParameters["size"].(map[string]any) + if compactRequired, _ := compactSize["required"].(bool); compactRequired || compactSize["default"] != "20" { + t.Fatalf("compact size contract = %#v, want required=false default=20", compactSize) + } + } else if full["pagination"] != nil || compact["pagination"] != nil { + t.Fatalf("non-list pagination must be absent: full=%#v compact=%#v", full["pagination"], compact["pagination"]) + } + }) + } +} diff --git a/internal/app/schema_shortcut_contract_test.go b/internal/app/schema_shortcut_contract_test.go index a4c5d83e..ed89cc80 100644 --- a/internal/app/schema_shortcut_contract_test.go +++ b/internal/app/schema_shortcut_contract_test.go @@ -16,12 +16,12 @@ import ( ) const ( - publicShortcutCount = 435 + publicShortcutCount = 415 // schemaPublishedShortcutCount counts every delivered *.shortcut_* tool, - // including the hidden historical minutes.shortcut_minutes_search contract. - schemaPublishedShortcutCount = 438 + // including reviewed hidden compatibility and unavailable contracts. + schemaPublishedShortcutCount = 453 // publiclyDeliveredShortcutCount is the public-catalog subset of that surface. - publiclyDeliveredShortcutCount = 435 + publiclyDeliveredShortcutCount = 415 ) func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) { @@ -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) diff --git a/internal/app/skill_command.go b/internal/app/skill_command.go index 8db71d07..e0087209 100644 --- a/internal/app/skill_command.go +++ b/internal/app/skill_command.go @@ -117,38 +117,114 @@ type CliSkillDTO struct { // (skill_setup.go) MUST have a matching path value here — enforced by // TestAgentSkillPathsCoversSetupHomes. var agentSkillPaths = map[string]string{ - // `agents` is the generic-agent sentinel: install scripts and `setup` - // special-case ~/.agents/skills as a no-checks-required fallback so a - // fresh machine without any IDE/agent registry still gets skills. - "agents": ".agents/skills", - "qoder": ".qoder/skills", - "qoderwork": ".qoderwork/skills", + // Universal agents. Those without an independent global directory map + // directly to the canonical ~/.agents/skills store. + "agents": ".agents/skills", + "amp": filepath.Join(".config", "agents", "skills"), + "antigravity": ".gemini/antigravity/skills", + "antigravity-cli": ".gemini/antigravity-cli/skills", + "codex": ".codex/skills", + "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", + "firebender": ".firebender/skills", + "gemini-cli": ".gemini/skills", + "github-copilot": ".copilot/skills", + "opencode": filepath.Join(".config", "opencode", "skills"), + "replit": filepath.Join(".config", "agents", "skills"), + "universal": filepath.Join(".config", "agents", "skills"), + "cline": ".agents/skills", + "dexto": ".agents/skills", + "kimi-code-cli": ".agents/skills", + "loaf": ".agents/skills", + "warp": ".agents/skills", + "zed": ".agents/skills", + + // Non-universal agents with global Skill directories. + "aider-desk": ".aider-desk/skills", + "astrbot": ".astrbot/data/skills", + "autohand-code": ".autohand/skills", + "augment": ".augment/skills", + "bob": ".bob/skills", + "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", + "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", + "codemaker": ".codemaker/skills", + "codestudio": ".codestudio/skills", + "command-code": ".commandcode/skills", + "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", + "crush": filepath.Join(".config", "crush", "skills"), + "devin": filepath.Join(".config", "devin", "skills"), + "droid": ".factory/skills", + "forgecode": ".forge/skills", + "goose": filepath.Join(".config", "goose", "skills"), + "grok": ".grok/skills", + "hermes-agent": ".hermes/skills", + "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", + "junie": ".junie/skills", + "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", + "kimchi": filepath.Join(".config", "kimchi", "harness", "skills"), + "kiro-cli": ".kiro/skills", + "kode": ".kode/skills", + "lingma": ".lingma/skills", + "mcpjam": ".mcpjam/skills", + "minimax-code": ".minimax/skills", + "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", + "mux": ".mux/skills", + "openhands": ".openhands/skills", + "ona": ".ona/skills", + "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", + "qoder-cn": ".qoder-cn/skills", + "qwen-code": ".qwen/skills", + "reasonix": ".reasonix/skills", + "rovodev": ".rovodev/skills", + "roo": ".roo/skills", + "tabnine-cli": ".tabnine/agent/skills", + "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", + "trae": ".trae/skills", + "trae-cn": ".trae-cn/skills", + "windsurf": ".codeium/windsurf/skills", + "zcode": ".zcode/skills", + "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", + "neovate": ".neovate/skills", + "pochi": ".pochi/skills", + "adal": ".adal/skills", + + // DWS compatibility aliases and DWS-only integrations. "claude": ".claude/skills", - "cursor": ".cursor/skills", - "codex": ".codex/skills", - "zcode": ".zcode/skills", - "opencode": filepath.Join(".config", "opencode", "skills"), - // IDE / agent registries also probed by `dws skill setup --target all`. - "gemini": ".gemini/skills", - "github": ".github/skills", - "windsurf": ".windsurf/skills", - "augment": ".augment/skills", - "cline": ".cline/skills", - "amp": ".amp/skills", - "kiro": ".kiro/skills", - "trae": ".trae/skills", - "openclaw": ".openclaw/skills", - "hermes": ".hermes/skills", + "gemini": ".gemini/skills", + "github": ".copilot/skills", + "hermes": ".hermes/skills", + "kiro": ".kiro/skills", + "qoderwork": ".qoderwork/skills", +} + +// Eve has project-scoped Skill directories but no upstream globalSkillsDir. +// Keep it in the advertised enumeration while failing explicitly instead of +// pretending that a global install configured Eve. +var unsupportedGlobalAgentTargets = map[string]string{ + "eve": "Eve 不支持全局 Skill 安装,请在 Eve 项目内配置 agent/skills", + "promptscript": "PromptScript 不支持全局 Skill 安装,请在项目内使用 .agents/skills", } // supportedTargets returns a sorted, comma-separated list of supported // targets. Sorted so help text and error messages stay stable across runs // (Go map iteration order is intentionally randomized). func supportedTargets() string { - targets := make([]string, 0, len(agentSkillPaths)+1) + targets := make([]string, 0, len(agentSkillPaths)+len(unsupportedGlobalAgentTargets)+1) for target := range agentSkillPaths { targets = append(targets, target) } + for target := range unsupportedGlobalAgentTargets { + targets = append(targets, target) + } sort.Strings(targets) targets = append(targets, ".") return strings.Join(targets, ", ") @@ -182,11 +258,28 @@ func formatAgentSkillPathsForHelp() string { sort.Strings(names) var b strings.Builder for _, n := range names { - fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, agentSkillPaths[n]) + installPath := agentSkillPaths[n] + if isUniversalSkillInstallTarget(n) { + installPath = ".agents/skills" + } + fmt.Fprintf(&b, " %-*s -> ~/%s/\n", maxWidth, n, installPath) } return b.String() } +// Universal Agents discover the shared ~/.agents/skills store directly. A +// marketplace install addressed to one of those Agent IDs must therefore +// publish to canonical instead of recreating an Agent-private duplicate. +func isUniversalSkillInstallTarget(target string) bool { + rel, ok := agentSkillPaths[target] + if !ok { + return false + } + base := filepath.Join("__home__", rel) + canonical := filepath.Join("__home__", ".agents", "skills") + return sameSkillSetupPath(base, canonical) || isUniversalSkillSetupBase(base) +} + func buildSkillCommand() *cobra.Command { cmd := &cobra.Command{ Use: "skill", @@ -485,8 +578,13 @@ func resolveSkillTargetPath(target string) (string, error) { return os.Getwd() } - // Look up predefined agent paths - relPath, ok := agentSkillPaths[strings.ToLower(target)] + target = strings.ToLower(target) + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return "", errors.New(reason) + } + + // Look up predefined agent paths. + _, ok := agentSkillPaths[target] if !ok { return "", fmt.Errorf("unsupported target") } @@ -496,7 +594,11 @@ func resolveSkillTargetPath(target string) (string, error) { return "", fmt.Errorf("failed to get home directory: %w", err) } - return filepath.Join(homeDir, relPath), nil + destination := resolveSkillSetupBase(homeDir, target) + if isUniversalSkillInstallTarget(target) { + destination = filepath.Join(homeDir, ".agents", "skills") + } + return destination, nil } // fetchSkillDownloadInfo calls the download API to get the skill download URL. diff --git a/internal/app/skill_command_test.go b/internal/app/skill_command_test.go index 381c87bc..b8406cb0 100644 --- a/internal/app/skill_command_test.go +++ b/internal/app/skill_command_test.go @@ -28,6 +28,7 @@ import ( "time" authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" ) func TestResolveSkillTargetPath(t *testing.T) { @@ -57,19 +58,19 @@ func TestResolveSkillTargetPath(t *testing.T) { { name: "cursor target", target: "cursor", - wantSuffix: filepath.Join(".cursor", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "codex target", target: "codex", - wantSuffix: filepath.Join(".codex", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { name: "opencode target", target: "opencode", - wantSuffix: filepath.Join(".config", "opencode", "skills"), + wantSuffix: filepath.Join(".agents", "skills"), wantErr: false, }, { @@ -118,6 +119,37 @@ func TestResolveSkillTargetPath(t *testing.T) { } } +func TestCrossPlatformCoverageUniversalSkillInstallTargetsUseCanonical(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + if isUniversalSkillInstallTarget("missing-agent") { + t.Fatal("unknown Agent target classified as universal") + } + for _, target := range []string{ + "amp", "antigravity", "antigravity-cli", "cline", "codex", "cursor", + "deepagents", "dexto", "firebender", "gemini", "gemini-cli", "github", + "github-copilot", "kimi-code-cli", "loaf", "opencode", "replit", + "universal", "warp", "zed", + } { + got, err := resolveSkillTargetPath(target) + if err != nil { + t.Fatalf("resolve %s: %v", target, err) + } + if want := filepath.Join(home, ".agents", "skills"); got != want { + t.Errorf("resolve %s = %s, want canonical %s", target, got, want) + } + } + for target, want := range map[string]string{ + "claude": filepath.Join(home, ".claude", "skills"), + "qoder": filepath.Join(home, ".qoder", "skills"), + "zcode": filepath.Join(home, ".zcode", "skills"), + } { + if got, err := resolveSkillTargetPath(target); err != nil || got != want { + t.Errorf("resolve non-universal %s = %s, %v; want %s", target, got, err, want) + } + } +} + func TestResolveSkillTargetPathCurrentDir(t *testing.T) { // Test "." target returns current working directory cwd, err := os.Getwd() @@ -477,8 +509,12 @@ func TestAgentSkillPathsCoversSetupHomes(t *testing.T) { for _, p := range agentSkillPaths { paths[p] = true } + legacyCleanupOnly := map[string]bool{ + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } for _, home := range skillSetupAgentHomes { - if !paths[home] { + if !paths[home] && !legacyCleanupOnly[home] { t.Errorf("skillSetupAgentHomes entry %q has no matching agentSkillPaths value — "+ "add it to agentSkillPaths so users can address it via --target ", home) } diff --git a/internal/app/skill_setup.go b/internal/app/skill_setup.go index 62ab2c17..873ca5a7 100644 --- a/internal/app/skill_setup.go +++ b/internal/app/skill_setup.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "runtime" "sort" "strings" "time" @@ -23,22 +24,77 @@ import ( // agree on the install footprint. var skillSetupAgentHomes = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", } const ( @@ -65,15 +121,20 @@ var ( skillSetupInteractive = isInteractiveTerminal skillSetupReadDir = os.ReadDir skillSetupStat = os.Stat + skillSetupLstat = os.Lstat + skillSetupGetenv = os.Getenv + skillSetupSymlink = os.Symlink skillSetupExecutable = os.Executable skillSetupGetwd = os.Getwd skillSetupUserHomeDir = os.UserHomeDir skillSetupRemoveAll = os.RemoveAll skillSetupBackupAndRemove = upgrade.BackupAndRemoveSkillDir + skillSetupRestoreBackup = upgrade.RestoreSkillPath skillSetupMkdirAll = os.MkdirAll skillSetupWalk = filepath.Walk skillSetupRel = filepath.Rel skillSetupReadlink = os.Readlink + skillSetupEvalSymlinks = filepath.EvalSymlinks skillSetupOpen = os.Open skillSetupOpenFile = os.OpenFile skillSetupWriteFile = os.WriteFile @@ -82,7 +143,10 @@ var ( skillSetupReadState = skillstate.Read skillSetupWriteState = skillstate.Write skillSetupRemoveState = skillstate.Remove + skillSetupPublishPath = upgrade.PublishSkillPathNoReplace + skillSetupRollbackPaths = upgrade.RollbackSkillPathPublications skillSetupNow = time.Now + skillSetupFoldPathCase = runtime.GOOS == "windows" ) type skillSetupBackup struct { @@ -91,9 +155,11 @@ type skillSetupBackup struct { } type skillSetupTargetPlan struct { - Destination string - Backups []skillSetupBackup - CleanupOnly bool + Destination string + CanonicalBase string + Backups []skillSetupBackup + CleanupOnly bool + LinkCanonical bool } type skillSetupPlan struct { @@ -149,8 +215,9 @@ multi 模式支持按产品挑选: 备份失败时保留原目录并跳过该目标,绝不静默删除。 · 所有将被移除的目录都会在确认前逐条列出。 -不带 --mode 时进入交互式询问;不带 --target 时铺到检测到的具体 Agent 目录; -未检测到具体 Agent 时才回退到 ~/.agents/skills,避免同一 Agent 扫描两份 Skill。 +不带 --mode 时进入交互式询问;Skill 统一安装到 ~/.agents/skills。 +DWS 会自动适配本机上检测到的 Agent;共享安装方式不可用时会自动改用兼容安装, +无需用户手动处理,也不会让同一个 Skill 重复出现。 skill 源默认取二进制内嵌的版本(升级二进制即升级 skill);--source / DWS_SKILL_SOURCE 可显式覆盖。`, Example: ` dws skill setup --mode multi --target claude --dry-run dws skill setup --mode multi --target claude`, @@ -243,7 +310,6 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } } - // filtered 决定 multi 安装的清理语义:带 -s/--skill 或 -x/--exclude // 时保持 additive(不动未列出的 sibling);全量安装与 install.sh / // install.js 对齐,清掉不在 bundle 内且有明确 DWS 所有权记录的过期 Skill。 @@ -316,6 +382,17 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { if err != nil { return err } + if mode == skillSetupModeMulti && len(migrateEventMiscTargets) > 0 { + retiredNames := append([]string(nil), multiSkillNames...) + if installsEventMiscCompanion && !containsSkillName(retiredNames, multiMiscSkill) { + retiredNames = append(retiredNames, multiMiscSkill) + } + if retireErr := retireMigratedUniversalSkills(migrateEventMiscTargets, retiredNames, out); retireErr != nil { + // Retiring an obsolete universal copy installs nothing; report it and + // keep the successful installation rather than failing the run. + fmt.Fprintf(errOut, " ⚠️ %v\n", retireErr) + } + } if skipped > 0 { return fmt.Errorf( "Skill 安装不完整(mode=%s, installed=%d, skipped=%d);修复失败原因后请重试 setup,或运行普通 upgrade 全量刷新预制 Skill", @@ -350,7 +427,9 @@ func runSkillSetup(cmd *cobra.Command, _ []string) error { } } fmt.Fprintf(out, "\n✅ Skill 安装完成(mode=%s, installed=%d, skipped=%d)\n", mode, installed, skipped) - fmt.Fprintln(out, "ℹ️ 若 Agent 会话已打开,请重启 Agent 或重新加载 Skills 后再验证路由。") + fmt.Fprintln(out, " 统一安装位置:~/.agents/skills") + fmt.Fprintln(out, " 已自动适配本机上检测到的 Agent") + fmt.Fprintln(out, "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效。") return nil } @@ -910,8 +989,9 @@ func isSkillSourceRoot(path, mode string) bool { } // resolveSkillSetupTargets returns the list of absolute Agent home destinations. -// If target == "all", returns every agent home whose parent directory exists. -// Otherwise returns the single matching home (whether or not it currently exists). +// The canonical ~/.agents/skills destination is always first. If target == +// "all", detected concrete Agent roots follow it. A specific target follows +// canonical as well so unknown/future Agents retain the universal copy. // // 末段约定: // - mono → /dws (单 skill,整个 src 拷成一个 dws 目录) @@ -923,15 +1003,88 @@ func resolveSkillSetupTargets(target, mode string) ([]string, error) { } target = strings.ToLower(strings.TrimSpace(target)) + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) if target == "" || target == "all" { return detectExistingAgentHomes(home, mode), nil } - rel, ok := agentSkillPaths[target] + if reason, unsupported := unsupportedGlobalAgentTargets[target]; unsupported { + return nil, errors.New(reason) + } + _, ok := agentSkillPaths[target] if !ok { return nil, fmt.Errorf("不支持的 --target 值: %s(可选 all, %s)", target, supportedTargets()) } - return []string{agentHomeForMode(filepath.Join(home, rel), mode)}, nil + dest := agentHomeForMode(resolveSkillSetupBase(home, target), mode) + if sameSkillSetupPath(dest, canonical) { + return []string{canonical}, nil + } + return []string{canonical, dest}, nil +} + +func resolveOpenClawSetupBase(home string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(home, name) + if info, err := skillSetupStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(home, ".openclaw", "skills") +} + +func resolveSkillSetupBase(home, target string) string { + switch target { + case "claude", "claude-code": + if custom := strings.TrimSpace(skillSetupGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + return filepath.Join(custom, "skills") + } + case "codex": + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "hermes", "hermes-agent": + if custom := strings.TrimSpace(skillSetupGetenv("HERMES_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "autohand-code": + if custom := strings.TrimSpace(skillSetupGetenv("AUTOHAND_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "grok": + if custom := strings.TrimSpace(skillSetupGetenv("GROK_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "mistral-vibe": + if custom := strings.TrimSpace(skillSetupGetenv("VIBE_HOME")); custom != "" { + return filepath.Join(custom, "skills") + } + case "openclaw": + return resolveOpenClawSetupBase(home) + case "opencode", "amp", "replit", "universal", "crush", "devin", "goose", "kimchi": + configHome := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(home, ".config") + } + switch target { + case "opencode": + return filepath.Join(configHome, "opencode", "skills") + case "amp", "replit", "universal": + return filepath.Join(configHome, "agents", "skills") + case "crush": + return filepath.Join(configHome, "crush", "skills") + case "devin": + return filepath.Join(configHome, "devin", "skills") + case "goose": + return filepath.Join(configHome, "goose", "skills") + case "kimchi": + return filepath.Join(configHome, "kimchi", "harness", "skills") + } + case "github", "github-copilot": + return filepath.Join(home, ".copilot", "skills") + case "windsurf": + return filepath.Join(home, ".codeium", "windsurf", "skills") + } + return filepath.Join(home, agentSkillPaths[target]) } // agentHomeForMode appends the mode-specific tail segment to an agent home base. @@ -943,79 +1096,141 @@ func agentHomeForMode(base, mode string) string { } func detectExistingAgentHomes(home, mode string) []string { - var specific []string + canonical := agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode) + dests := []string{canonical} + canonicalKey := skillSetupPathKey(canonical) + seen := map[string]bool{canonicalKey: true} + addDetected := func(rel, base string) { + detectedDir := filepath.Dir(base) + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".config/kimchi/harness/skills", ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(base)) + case ".zcode/skills": + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so setup matches npm, Shell, and PowerShell. + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + detectedDir = "" + } + case ".minimax/skills": + if info, err := skillSetupStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + detectedDir = "" + } + } + if detectedDir != "" { + if info, err := skillSetupStat(detectedDir); err != nil || !info.IsDir() { + return + } + } + dest := agentHomeForMode(base, mode) + key := skillSetupPathKey(dest) + if !seen[key] { + seen[key] = true + dests = append(dests, dest) + } + } for i, rel := range skillSetupAgentHomes { if i == 0 { continue } base := filepath.Join(home, rel) - parent := filepath.Dir(base) - if info, err := skillSetupStat(parent); err != nil || !info.IsDir() { - continue + switch filepath.ToSlash(filepath.Clean(rel)) { + case ".claude/skills": + base = resolveSkillSetupBase(home, "claude-code") + case ".codex/skills": + base = resolveSkillSetupBase(home, "codex") + case ".hermes/skills": + base = resolveSkillSetupBase(home, "hermes-agent") + case ".autohand/skills": + base = resolveSkillSetupBase(home, "autohand-code") + case ".grok/skills": + base = resolveSkillSetupBase(home, "grok") + case ".vibe/skills": + base = resolveSkillSetupBase(home, "mistral-vibe") + case ".openclaw/skills": + base = resolveSkillSetupBase(home, "openclaw") + case ".config/opencode/skills": + base = resolveSkillSetupBase(home, "opencode") + case ".config/agents/skills": + base = resolveSkillSetupBase(home, "amp") + case ".config/crush/skills": + base = resolveSkillSetupBase(home, "crush") + case ".config/devin/skills": + base = resolveSkillSetupBase(home, "devin") + case ".config/goose/skills": + base = resolveSkillSetupBase(home, "goose") + case ".config/kimchi/harness/skills": + base = resolveSkillSetupBase(home, "kimchi") } - specific = append(specific, agentHomeForMode(base, mode)) + addDetected(rel, base) } - if len(specific) > 0 { - return specific + for _, target := range []string{"github-copilot", "windsurf"} { + addDetected(agentSkillPaths[target], resolveSkillSetupBase(home, target)) } - return []string{agentHomeForMode(filepath.Join(home, skillSetupAgentHomes[0]), mode)} + return dests } -func genericSkillCleanupTarget(dests []string, managed map[string]bool) (*skillSetupTargetPlan, error) { - // Derive HOME from a concrete Agent destination instead of resolving it a - // second time. The destinations were already resolved from HOME by the - // caller, and a later/transient UserHomeDir failure must not turn an - // otherwise valid setup plan into an error. Direct/custom destinations that - // do not match a known concrete Agent root have no generic-root migration. - home := "" - for _, dest := range dests { - base := dest - if filepath.Base(dest) == "dws" { - base = filepath.Dir(dest) - } - base = filepath.Clean(base) - for i, rel := range skillSetupAgentHomes { - if i == 0 { - continue - } - suffix := filepath.Clean(filepath.FromSlash(rel)) - needle := string(filepath.Separator) + suffix - if strings.HasSuffix(base, needle) { - home = strings.TrimSuffix(base, needle) - break - } - } - if home != "" { - break - } +func skillSetupBaseForMode(dest, mode string) string { + if mode == skillSetupModeMono { + return filepath.Dir(dest) } - if home == "" { - return nil, nil - } - genericBase := filepath.Join(home, ".agents", "skills") + return dest +} - target := &skillSetupTargetPlan{Destination: genericBase, CleanupOnly: true} - add := func(path, reason string) { - if info, statErr := skillSetupStat(path); statErr == nil && info.IsDir() { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: reason}) +func isUniversalSkillSetupBase(base string) bool { + cleanBase := filepath.Clean(base) + if custom := strings.TrimSpace(skillSetupGetenv("CODEX_HOME")); custom != "" && sameSkillSetupPath(cleanBase, filepath.Join(custom, "skills")) { + return true + } + if custom := strings.TrimSpace(skillSetupGetenv("XDG_CONFIG_HOME")); custom != "" { + if sameSkillSetupPath(cleanBase, filepath.Join(custom, "agents", "skills")) || + sameSkillSetupPath(cleanBase, filepath.Join(custom, "opencode", "skills")) { + return true } } - add(filepath.Join(genericBase, "dws"), skillSetupBackupMutual) - entries, readErr := skillSetupReadDir(genericBase) - if readErr != nil && !errors.Is(readErr, os.ErrNotExist) { - return nil, fmt.Errorf("扫描通用 Skill 根目录失败 %s: %w", genericBase, readErr) - } - for _, entry := range entries { - path := filepath.Join(genericBase, entry.Name()) - if entry.IsDir() && isManagedDWSMultiSkillDir(path, managed) { - target.Backups = append(target.Backups, skillSetupBackup{Path: path, Reason: skillSetupBackupStale}) + base = filepath.ToSlash(cleanBase) + for rel := range map[string]bool{ + ".config/agents/skills": true, ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, ".codex/skills": true, + ".cursor/skills": true, ".deepagents/agent/skills": true, + ".firebender/skills": true, ".gemini/skills": true, + ".copilot/skills": true, ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only. + ".github/skills": true, ".windsurf/skills": true, + ".cline/skills": true, ".amp/skills": true, + } { + if strings.HasSuffix(base, "/"+rel) { + return true } } - if len(target.Backups) == 0 { - return nil, nil + return false +} + +func sameSkillSetupPath(left, right string) bool { + return skillSetupPathKey(left) == skillSetupPathKey(right) +} + +func skillSetupPathKey(path string) string { + clean := filepath.Clean(path) + if skillSetupFoldPathCase { + clean = strings.ToLower(clean) } - sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - return target, nil + return clean +} + +func canonicalSkillSetupBase(dests []string, mode string) string { + for _, dest := range dests { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, mode))) + if strings.HasSuffix(base, "/.agents/skills") { + return skillSetupBaseForMode(dest, mode) + } + } + return "" +} + +func samePhysicalSkillSetupPath(left, right string) bool { + leftReal, leftErr := skillSetupEvalSymlinks(left) + rightReal, rightErr := skillSetupEvalSymlinks(right) + return leftErr == nil && rightErr == nil && sameSkillSetupPath(leftReal, rightReal) } func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filtered bool) (*skillSetupPlan, error) { @@ -1030,10 +1245,26 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } sort.Strings(plan.MultiSkillNames) sortedDests := append([]string(nil), dests...) - sort.Strings(sortedDests) + sort.Slice(sortedDests, func(i, j int) bool { + leftCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[i], mode))), "/.agents/skills") + rightCanonical := strings.HasSuffix(filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(sortedDests[j], mode))), "/.agents/skills") + if leftCanonical != rightCanonical { + return leftCanonical + } + return sortedDests[i] < sortedDests[j] + }) managedNames := currentManagedSkillNames() + canonicalBase := canonicalSkillSetupBase(sortedDests, mode) for _, dest := range sortedDests { - target := skillSetupTargetPlan{Destination: dest} + base := skillSetupBaseForMode(dest, mode) + target := skillSetupTargetPlan{Destination: dest, CanonicalBase: canonicalBase} + if canonicalBase != "" && filepath.Clean(base) != filepath.Clean(canonicalBase) { + if isUniversalSkillSetupBase(base) { + target.CleanupOnly = true + } else { + target.LinkCanonical = true + } + } seen := map[string]bool{} add := func(path, reason string) { if seen[path] { @@ -1077,26 +1308,22 @@ func buildSkillSetupPlan(mode, src string, dests, multiSkillNames []string, filt } } for _, path := range replacements { - info, statErr := skillSetupStat(path) + if target.LinkCanonical && samePhysicalSkillSetupPath(path, filepath.Join(target.CanonicalBase, filepath.Base(path))) { + continue + } + _, statErr := skillSetupLstat(path) if statErr != nil { if errors.Is(statErr, os.ErrNotExist) { continue } return nil, fmt.Errorf("检查将被替换的 Skill 失败 %s: %w", path, statErr) } - if info.IsDir() { - add(path, skillSetupBackupReplace) - } + add(path, skillSetupBackupReplace) } sort.Slice(target.Backups, func(i, j int) bool { return target.Backups[i].Path < target.Backups[j].Path }) - plan.Targets = append(plan.Targets, target) - } - cleanupTarget, cleanupErr := genericSkillCleanupTarget(sortedDests, managedNames) - if cleanupErr != nil { - return nil, cleanupErr - } - if cleanupTarget != nil { - plan.Targets = append(plan.Targets, *cleanupTarget) + if !target.CleanupOnly || len(target.Backups) > 0 { + plan.Targets = append(plan.Targets, target) + } } return plan, nil } @@ -1140,6 +1367,33 @@ func configureEventMiscMigrationPlan(plan *skillSetupPlan, targets []string, ins } } +func retireMigratedUniversalSkills(targets, names []string, out io.Writer) error { + home, err := skillSetupUserHomeDir() + if err != nil { + return fmt.Errorf("无法解析 HOME 以退役 universal Agent 旧副本: %w", err) + } + seen := map[string]bool{} + var victims []skillSetupBackup + for _, target := range targets { + if !isUniversalSkillSetupBase(target) { + continue + } + for _, name := range names { + path := filepath.Join(target, name) + if seen[path] { + continue + } + seen[path] = true + victims = append(victims, skillSetupBackup{Path: path, Reason: skillSetupBackupReplace}) + } + } + sort.Slice(victims, func(i, j int) bool { return victims[i].Path < victims[j].Path }) + if _, err := backupSkillSetupTarget(home, victims, out); err != nil { + return fmt.Errorf("退役 universal Agent Event/misc 旧副本失败,已回滚: %w", err) + } + return nil +} + func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, "📦 将安装 skill:\n mode: %s\n source: %s\n", plan.Mode, plan.Source) if plan.Mode == skillSetupModeMulti { @@ -1148,12 +1402,14 @@ func renderSkillSetupPlan(out io.Writer, plan *skillSetupPlan) { fmt.Fprintf(out, " · %s\n", name) } } - fmt.Fprintln(out, " destinations:") + fmt.Fprintln(out, " 安装与适配位置:") for _, target := range plan.Targets { if target.CleanupOnly { - fmt.Fprintf(out, " - %s (仅迁移旧的通用 DWS 副本)\n", target.Destination) + fmt.Fprintf(out, " - %s(移除该 Agent 中的旧版 DWS Skills,改用统一安装位置)\n", target.Destination) + } else if target.LinkCanonical { + fmt.Fprintf(out, " - %s(自动配置此 Agent 使用统一安装位置)\n", target.Destination) } else { - fmt.Fprintf(out, " - %s\n", target.Destination) + fmt.Fprintf(out, " - %s(统一安装位置)\n", target.Destination) } } fmt.Fprintln(out, " 将备份并移除(先保存到 ~/.dws/skill-backups/):") @@ -1324,8 +1580,12 @@ func installMultiSkillsWithEventMigration( } migrationSet := make(map[string]struct{}, len(migrationTargets)) + physicalMigrationTargets := make([]string, 0, len(migrationTargets)) for _, dest := range migrationTargets { migrationSet[dest] = struct{}{} + if !isUniversalSkillSetupBase(dest) { + physicalMigrationTargets = append(physicalMigrationTargets, dest) + } } var ordinaryTargets []string for _, dest := range dests { @@ -1334,23 +1594,47 @@ func installMultiSkillsWithEventMigration( } } - if len(ordinaryTargets) > 0 { + var canonicalTargets, otherOrdinaryTargets []string + for _, dest := range ordinaryTargets { + base := filepath.ToSlash(filepath.Clean(skillSetupBaseForMode(dest, skillSetupModeMulti))) + if strings.HasSuffix(base, "/.agents/skills") { + canonicalTargets = append(canonicalTargets, dest) + } else { + otherOrdinaryTargets = append(otherOrdinaryTargets, dest) + } + } + installOrdinary := func(names, targets []string) error { + if len(targets) == 0 { + return nil + } var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, skillNames, ordinaryTargets, out, errOut, filtered) + n, nSkipped, err = skillSetupInstallMulti(src, names, targets, out, errOut, filtered) installed += n skipped += nSkipped if err != nil { - return installed, skipped, err + return err } if nSkipped > 0 { - return installed, skipped, fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) + return fmt.Errorf("multi Skill 安装不完整(skipped=%d);已保留折叠版 Event/misc,未执行迁移", nSkipped) } + return nil + } + canonicalNames := append([]string(nil), skillNames...) + if !containsSkillName(canonicalNames, multiMiscSkill) { + canonicalNames = append(canonicalNames, multiMiscSkill) + sort.Strings(canonicalNames) + } + if err := installOrdinary(canonicalNames, canonicalTargets); err != nil { + return installed, skipped, err + } + if err := installOrdinary(skillNames, otherOrdinaryTargets); err != nil { + return installed, skipped, err } // The folded pair is excluded from the ordinary best-effort installer. All // other selected skills (especially dingtalk-shared) must succeed before the // old Event route is touched. - for _, dest := range migrationTargets { + for _, dest := range physicalMigrationTargets { if cleanupErr := cleanupMutualExclusion(dest, skillSetupModeMulti, out, errOut); cleanupErr != nil { return installed, skipped + len(skillNames), cleanupErr } @@ -1361,9 +1645,9 @@ func installMultiSkillsWithEventMigration( prerequisiteNames = append(prerequisiteNames, name) } } - if len(prerequisiteNames) > 0 { + if len(prerequisiteNames) > 0 && len(physicalMigrationTargets) > 0 { var n, nSkipped int - n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, migrationTargets, out, errOut, true) + n, nSkipped, err = skillSetupInstallMulti(src, prerequisiteNames, physicalMigrationTargets, out, errOut, true) installed += n skipped += nSkipped if err != nil { @@ -1374,7 +1658,7 @@ func installMultiSkillsWithEventMigration( } } - migrated, migrationErr := migrateEventMiscAtomically(src, migrationTargets, out, errOut) + migrated, migrationErr := migrateEventMiscAtomically(src, physicalMigrationTargets, out, errOut) installed += migrated if migrationErr != nil { return installed, skipped, migrationErr @@ -1646,9 +1930,32 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s err = errors.Join(err, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } }() + realStageParent, realParentErr := skillSetupEvalSymlinks(stageParent) + if realParentErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", stageParent, realParentErr) + } stageOne := func(src, dest string) error { stagedDir := filepath.Join(stageRoot, filepath.Base(dest)) + if target.LinkCanonical { + canonicalTarget := filepath.Join(target.CanonicalBase, filepath.Base(dest)) + if samePhysicalSkillSetupPath(dest, canonicalTarget) { + return nil + } + realCanonicalTarget, realTargetErr := skillSetupEvalSymlinks(canonicalTarget) + if realTargetErr != nil { + return fmt.Errorf("解析 canonical Skill 失败 %s: %w", canonicalTarget, realTargetErr) + } + relTarget, relErr := skillSetupRel(realStageParent, realCanonicalTarget) + if relErr != nil { + return fmt.Errorf("计算 Skill 相对链接失败 %s: %w", canonicalTarget, relErr) + } + if linkErr := skillSetupSymlink(relTarget, stagedDir); linkErr != nil { + return fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedDir, relTarget, linkErr) + } + staged = append(staged, skillSetupStagedDir{staged: stagedDir, dest: dest}) + return nil + } if err := skillSetupMkdirAll(stagedDir, 0o755); err != nil { return fmt.Errorf("创建 Skill staging 目录失败 %s: %w", stagedDir, err) } @@ -1675,16 +1982,11 @@ func stageSkillSetupTarget(plan *skillSetupPlan, target skillSetupTargetPlan) (s // restoreSkillSetupTarget removes a partially published replacement and // restores every original directory from its exact backup path. -func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := skillSetupRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSetupTarget(published []upgrade.SkillPathPublication, backups []skillSetupBackedUpDir) error { + restoreErr := skillSetupRollbackPaths(published) for i := len(backups) - 1; i >= 0; i-- { item := backups[i] - if _, err := skillSetupStat(item.original); err == nil { + if _, err := skillSetupLstat(item.original); err == nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复目标仍存在 %s;备份保留于 %s", item.original, item.backup)) continue } else if !errors.Is(err, os.ErrNotExist) { @@ -1695,7 +1997,7 @@ func restoreSkillSetupTarget(published []string, backups []skillSetupBackedUpDir restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录失败 %s: %w;备份保留于 %s", filepath.Dir(item.original), err, item.backup)) continue } - if err := skillSetupPublishRename(item.backup, item.original); err != nil { + if err := skillSetupRestoreBackup(item.backup, item.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w;备份保留于 %s", item.original, err, item.backup)) } } @@ -1728,45 +2030,53 @@ func backupSkillSetupTarget(home string, planned []skillSetupBackup, out io.Writ } func publishSkillSetupTarget(staged []skillSetupStagedDir, backups []skillSetupBackedUpDir) error { - published := make([]string, 0, len(staged)) + published := make([]upgrade.SkillPathPublication, 0, len(staged)) for _, item := range staged { - // Record before rename so rollback also removes a destination created by - // a platform-specific partial failure. - published = append(published, item.dest) - if err := skillSetupPublishRename(item.staged, item.dest); err != nil { + publication, err := skillSetupPublishPath(item.staged, item.dest) + if err != nil { publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", item.dest, err) if restoreErr := restoreSkillSetupTarget(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("Skill setup 回滚不完整: %w", restoreErr)) } return publishErr } + published = append(published, publication) } return nil } func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (installed, skipped int, err error) { home, homeErr := skillSetupUserHomeDir() - perTarget := 1 - if plan.Mode == skillSetupModeMulti { - perTarget = len(plan.MultiSkillNames) + hasCanonicalDependents := false + for _, candidate := range plan.Targets { + if candidate.CanonicalBase != "" && !sameSkillSetupPath(skillSetupBaseForMode(candidate.Destination, plan.Mode), candidate.CanonicalBase) { + hasCanonicalDependents = true + break + } } for _, target := range plan.Targets { + perTarget := 1 + if plan.Mode == skillSetupModeMulti { + perTarget = len(plan.MultiSkillNames) + } + isCanonical := target.CanonicalBase != "" && sameSkillSetupPath(skillSetupBaseForMode(target.Destination, plan.Mode), target.CanonicalBase) if target.CleanupOnly { - if skipped > 0 { - continue - } + // Nothing is installed below a universal root, so a stale copy that + // resists retirement must not count as a skipped install: any skipped + // count fails the whole setup, even when every real target succeeded. if homeErr != nil { - fmt.Fprintf(errOut, " ✗ 无法解析 HOME,保留通用 Skill 副本 %s: %v\n", target.Destination, homeErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ 无法解析 HOME,保留 universal Agent 旧副本 %s: %v\n", target.Destination, homeErr) continue } if _, cleanupErr := backupSkillSetupTarget(home, target.Backups, out); cleanupErr != nil { - fmt.Fprintf(errOut, " ✗ 通用 Skill 副本迁移失败,已回滚 %s: %v\n", target.Destination, cleanupErr) - skipped++ + fmt.Fprintf(errOut, " ⚠️ universal Agent 旧副本迁移失败,已回滚,可手动删除 %s: %v\n", target.Destination, cleanupErr) } continue } if len(target.Backups) > 0 && homeErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("无法解析 HOME,canonical Skill 刷新中止 %s: %w", target.Destination, homeErr) + } if plan.Mode == skillSetupModeMono { fmt.Fprintf(errOut, " ✗ 无法解析 HOME,跳过刷新(保留原目录) %s: %v\n", target.Destination, homeErr) } else { @@ -1777,7 +2087,16 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install } stageRoot, staged, stageErr := stageSkillSetupTarget(plan, target) + if stageErr != nil && target.LinkCanonical { + fmt.Fprintf(errOut, " ℹ️ %s 无法使用共享安装方式,正在自动改用兼容安装\n", target.Destination) + fallback := target + fallback.LinkCanonical = false + stageRoot, staged, stageErr = stageSkillSetupTarget(plan, fallback) + } if stageErr != nil { + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill staging 失败 %s: %w", target.Destination, stageErr) + } fmt.Fprintf(errOut, " ✗ Skill staging 失败,保留原集合 %s: %v\n", target.Destination, stageErr) skipped += perTarget continue @@ -1787,6 +2106,9 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr := skillSetupRemoveAll(stageRoot); cleanupErr != nil { backupErr = errors.Join(backupErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } + if isCanonical && hasCanonicalDependents { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 备份失败,已执行回滚 %s: %w", target.Destination, backupErr) + } fmt.Fprintf(errOut, " ✗ Skill 备份失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, backupErr) skipped += perTarget continue @@ -1797,7 +2119,19 @@ func executeSkillSetupPlan(plan *skillSetupPlan, out, errOut io.Writer) (install if cleanupErr != nil { publishErr = errors.Join(publishErr, fmt.Errorf("清理 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) } - fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + if isCanonical && hasCanonicalDependents { + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %w", target.Destination, publishErr) + } + return installed, skipped + perTarget, fmt.Errorf("canonical Skill 发布失败,已执行回滚 %s: %w", target.Destination, publishErr) + } + if errors.Is(publishErr, upgrade.ErrSkillPathPublicationUncertain) { + // The destination may belong to a concurrent writer and was + // deliberately retained; the rollback refuses to displace it. + fmt.Fprintf(errOut, " ✗ Skill 发布状态不确定,目标可能属于并发写入并已保留 %s: %v\n", target.Destination, publishErr) + } else { + fmt.Fprintf(errOut, " ✗ Skill 发布失败,已执行回滚,跳过整个 Agent 目标 %s: %v\n", target.Destination, publishErr) + } skipped += perTarget continue } @@ -1836,7 +2170,7 @@ func staleMultiSkillVictimsWithError(dest string, keep []string, managed ...map[ } var victims []string for _, e := range entries { - if !e.IsDir() || keepSet[e.Name()] { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || keepSet[e.Name()] { continue } if !isManagedDWSMultiSkillDir(filepath.Join(dest, e.Name()), managed...) { diff --git a/internal/app/skill_setup_canonical_failures_test.go b/internal/app/skill_setup_canonical_failures_test.go new file mode 100644 index 00000000..9f492abb --- /dev/null +++ b/internal/app/skill_setup_canonical_failures_test.go @@ -0,0 +1,160 @@ +package app + +import ( + "bytes" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" +) + +func canonicalFailurePlan(t *testing.T) (string, *skillSetupPlan) { + t.Helper() + home := t.TempDir() + src := t.TempDir() + skill := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skill, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skill, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + dependent := filepath.Join(home, ".claude", "skills") + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, dependent}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + return home, plan +} + +func TestCrossPlatformCoverageSkillSetupCanonicalHomeBackupAndPublishFailures(t *testing.T) { + t.Run("home", func(t *testing.T) { + _, plan := canonicalFailurePlan(t) + canonical := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonical, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: canonical, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("home denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 刷新中止") { + t.Fatalf("home failure = %v", err) + } + }) + + t.Run("backup", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + victim := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + plan.Targets[0].Backups = []skillSetupBackup{{Path: victim, Reason: skillSetupBackupReplace}} + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 备份失败") { + t.Fatalf("backup failure = %v", err) + } + }) + + t.Run("publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, errors.New("publish denied") + }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical Skill 发布失败") { + t.Fatalf("publish failure = %v", err) + } + }) + + t.Run("uncertain-publish", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + }) + errOut := &bytes.Buffer{} + _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 发布状态不确定") { + t.Fatalf("uncertain publish = %v", err) + } + // The destination was deliberately retained, so the canonical error + // must not claim a rollback happened. + if strings.Contains(err.Error(), "回滚") { + t.Fatalf("uncertain error must not claim a rollback: %v", err) + } + }) + + t.Run("uncertain dependent target is retained and reported", func(t *testing.T) { + home, plan := canonicalFailurePlan(t) + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + if strings.HasPrefix(destination, filepath.Join(home, ".claude")) { + return upgrade.SkillPathPublication{}, fmt.Errorf("并发写入: %w", upgrade.ErrSkillPathPublicationUncertain) + } + return originalPublish(staged, destination) + }) + errOut := &bytes.Buffer{} + _, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, errOut) + if err != nil { + t.Fatalf("dependent uncertain publish = %v", err) + } + if skipped != 1 { + t.Fatalf("skipped = %d, want 1", skipped) + } + if !strings.Contains(errOut.String(), "发布状态不确定") || strings.Contains(errOut.String(), "已执行回滚") { + t.Fatalf("errOut = %q, want retained-destination notice", errOut.String()) + } + }) +} + +func TestCrossPlatformCoverageSkillSetupLinkResolutionFailures(t *testing.T) { + home, plan := canonicalFailurePlan(t) + canonicalTarget := filepath.Join(plan.Targets[0].Destination, "dingtalk-chat") + if err := os.MkdirAll(canonicalTarget, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(canonicalTarget, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + linked := plan.Targets[1] + + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == linked.Destination { + return "", errors.New("parent denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical parent error = %v", err) + } + }) + + t.Run("canonical-target", func(t *testing.T) { + testseam.Swap(t, &skillSetupEvalSymlinks, func(path string) (string, error) { + if path == canonicalTarget { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical target error = %v", err) + } + }) + + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &skillSetupRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageSkillSetupTarget(plan, linked); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative path error = %v", err) + } + }) + + _ = home +} diff --git a/internal/app/skill_setup_canonical_test.go b/internal/app/skill_setup_canonical_test.go new file mode 100644 index 00000000..1932e55b --- /dev/null +++ b/internal/app/skill_setup_canonical_test.go @@ -0,0 +1,395 @@ +package app + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillSetupCanonicalTargetsAndAgentCapabilities(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupAgentHomes, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + canonical := filepath.Join(home, ".agents", "skills") + if len(dests) != 4 || dests[0] != canonical { + t.Fatalf("targets = %v", dests) + } + + src := t.TempDir() + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + dir := filepath.Join(src, name) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeSkills := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeSkills, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeSkills, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeSkills, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + installed, skipped, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}) + if err != nil || skipped != 0 || installed != 6 { // canonical + two linked Agents, two Skills each + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("Codex duplicate remains: %v", err) + } + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("link %s = %#v, %v", link, info, err) + } + } + } + // Re-running setup must recognize the existing links as already correct; + // canonical refreshes in place without turning links into copied trees. + plan, err = buildSkillSetupPlan(skillSetupModeMulti, src, dests, []string{"dingtalk-chat", "dingtalk-shared"}, false) + if err != nil { + t.Fatal(err) + } + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err != nil { + t.Fatal(err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent setup replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCrossPlatformCoverageSkillSetupDetectsShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. + home := t.TempDir() + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") + originalStat := skillSetupStat + testseam.Swap(t, &skillSetupStat, func(path string) (os.FileInfo, error) { + if path == zcodeApp || path == minimaxApp { + return appInfo, nil + } + return originalStat(path) + }) + dests := detectExistingAgentHomes(home, skillSetupModeMulti) + for _, target := range []string{ + filepath.Join(home, ".config", "kimchi", "harness", "skills"), + filepath.Join(home, ".tabnine", "agent", "skills"), + filepath.Join(home, ".zcode", "skills"), + filepath.Join(home, ".minimax", "skills"), + } { + if !containsSkillName(dests, target) { + t.Errorf("detected targets %v missing %s", dests, target) + } + } +} + +func TestCrossPlatformCoverageSkillSetupCustomRootsAliasesAndUniversalTargets(t *testing.T) { + home := t.TempDir() + customClaude := filepath.Join(t.TempDir(), "claude") + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + for _, root := range []string{customClaude, customCodex, customHermes, filepath.Join(home, ".moltbot"), filepath.Join(home, ".copilot"), filepath.Join(home, ".config", "opencode"), filepath.Join(home, ".config", "agents"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(name string) string { + switch name { + case "CLAUDE_CONFIG_DIR": + return customClaude + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + dests, err := resolveSkillSetupTargets("all", skillSetupModeMulti) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + filepath.Join(home, ".agents", "skills"), + filepath.Join(customClaude, "skills"), + filepath.Join(customCodex, "skills"), + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".moltbot", "skills"), + filepath.Join(home, ".copilot", "skills"), + filepath.Join(home, ".config", "opencode", "skills"), + filepath.Join(home, ".config", "agents", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + found := false + for _, got := range dests { + if sameSkillSetupPath(got, want) { + found = true + break + } + } + if !found { + t.Fatalf("resolved targets %v missing %s", dests, want) + } + } + if !isUniversalSkillSetupBase(filepath.Join(customCodex, "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "opencode", "skills")) || !isUniversalSkillSetupBase(filepath.Join(home, ".config", "agents", "skills")) { + t.Fatal("custom Codex, OpenCode, and Amp must be universal cleanup-only targets") + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalFailureStopsDependentTargets(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claude, 0o755); err != nil { + t.Fatal(err) + } + oldClaude := filepath.Join(claude, "dingtalk-chat") + if err := os.MkdirAll(oldClaude, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldClaude, "SKILL.md"), []byte("old remains"), 0o644); err != nil { + t.Fatal(err) + } + src := t.TempDir() + if err := os.MkdirAll(filepath.Join(src, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "dingtalk-chat", "SKILL.md"), []byte("new"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, src, []string{canonical, claude}, []string{"dingtalk-chat"}, true) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { return errors.New("canonical copy denied") }) + if _, _, err := executeSkillSetupPlan(plan, &bytes.Buffer{}, &bytes.Buffer{}); err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("canonical failure = %v", err) + } + body, readErr := os.ReadFile(filepath.Join(oldClaude, "SKILL.md")) + if readErr != nil || string(body) != "old remains" { + t.Fatalf("dependent Claude target changed: %q, %v", body, readErr) + } +} + +func TestCrossPlatformCoverageSkillSetupCanonicalCopyFallbackMessage(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + claude := filepath.Join(home, ".claude", "skills") + src := t.TempDir() + skillSrc := filepath.Join(src, "dingtalk-chat") + if err := os.MkdirAll(skillSrc, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(skillSrc, "SKILL.md"), []byte("chat"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupSymlink, func(string, string) error { return errors.New("links unavailable") }) + + plan, err := buildSkillSetupPlan( + skillSetupModeMulti, + src, + []string{canonical, claude}, + []string{"dingtalk-chat"}, + false, + ) + if err != nil { + t.Fatal(err) + } + var out, errOut bytes.Buffer + installed, skipped, err := executeSkillSetupPlan(plan, &out, &errOut) + if err != nil || installed != 2 || skipped != 0 { + t.Fatalf("execute = installed %d skipped %d err %v", installed, skipped, err) + } + if !strings.Contains(errOut.String(), "自动改用兼容安装") { + t.Fatalf("human-readable fallback message missing: %s", errOut.String()) + } + info, err := os.Lstat(filepath.Join(claude, "dingtalk-chat")) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } +} + +func TestCrossPlatformCoverageUpstreamAgentEnumerationAndEffectiveRoots(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillUserHomeDir, func() (string, error) { return home, nil }) + testseam.Swap(t, &skillSetupGetenv, func(string) string { return "" }) + + expected := map[string]string{ + "aider-desk": ".aider-desk/skills", "amp": ".config/agents/skills", + "antigravity": ".gemini/antigravity/skills", "antigravity-cli": ".gemini/antigravity-cli/skills", + "astrbot": ".astrbot/data/skills", "autohand-code": ".autohand/skills", + "augment": ".augment/skills", "bob": ".bob/skills", "claude-code": ".claude/skills", + "openclaw": ".openclaw/skills", "cline": ".agents/skills", "codearts-agent": ".codeartsdoer/skills", + "codebuddy": ".codebuddy/skills", "codemaker": ".codemaker/skills", "codestudio": ".codestudio/skills", + "codex": ".codex/skills", "command-code": ".commandcode/skills", "continue": ".continue/skills", + "cortex": ".snowflake/cortex/skills", "crush": ".config/crush/skills", "cursor": ".cursor/skills", + "deepagents": ".deepagents/agent/skills", "devin": ".config/devin/skills", "dexto": ".agents/skills", + "droid": ".factory/skills", "firebender": ".firebender/skills", "forgecode": ".forge/skills", + "gemini-cli": ".gemini/skills", "github-copilot": ".copilot/skills", "goose": ".config/goose/skills", + "grok": ".grok/skills", "hermes-agent": ".hermes/skills", "inference-sh": ".inferencesh/skills", + "jazz": ".jazz/skills", "junie": ".junie/skills", "iflow-cli": ".iflow/skills", + "kilo": ".kilocode/skills", "kimchi": ".config/kimchi/harness/skills", "kimi-code-cli": ".agents/skills", + "kiro-cli": ".kiro/skills", "kode": ".kode/skills", "lingma": ".lingma/skills", "loaf": ".agents/skills", + "mcpjam": ".mcpjam/skills", "minimax-code": ".minimax/skills", "mistral-vibe": ".vibe/skills", + "moxby": ".moxby/skills", "mux": ".mux/skills", "opencode": ".config/opencode/skills", + "openhands": ".openhands/skills", "ona": ".ona/skills", "pi": ".pi/agent/skills", + "qoder": ".qoder/skills", "qoder-cn": ".qoder-cn/skills", "qwen-code": ".qwen/skills", + "replit": ".config/agents/skills", "reasonix": ".reasonix/skills", "rovodev": ".rovodev/skills", + "roo": ".roo/skills", "tabnine-cli": ".tabnine/agent/skills", "terramind": ".terramind/skills", + "tinycloud": ".tinycloud/skills", "trae": ".trae/skills", "trae-cn": ".trae-cn/skills", + "universal": ".config/agents/skills", "warp": ".agents/skills", "windsurf": ".codeium/windsurf/skills", + "zed": ".agents/skills", "zcode": ".zcode/skills", "zencoder": ".zencoder/skills", + "zenflow": ".zencoder/skills", "neovate": ".neovate/skills", "pochi": ".pochi/skills", "adal": ".adal/skills", + } + if got := len(expected) + len(unsupportedGlobalAgentTargets); got != 76 { + t.Fatalf("upstream agent enumeration = %d, want 76", got) + } + for target, rel := range expected { + mapped, ok := agentSkillPaths[target] + if !ok || filepath.Clean(mapped) != filepath.Clean(rel) { + t.Errorf("agent %s map = %q, want %q", target, mapped, rel) + } + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, filepath.Join(home, filepath.FromSlash(rel))) { + t.Errorf("agent %s effective root = %q, want %q", target, got, filepath.Join(home, rel)) + } + } + for _, target := range []string{"eve", "promptscript"} { + if _, err := resolveSkillSetupTargets(target, skillSetupModeMulti); err == nil { + t.Errorf("%s unexpectedly resolved a global setup root", target) + } + if _, err := resolveSkillTargetPath(target); err == nil { + t.Errorf("%s unexpectedly resolved a marketplace install root", target) + } + } + if got := supportedTargets(); !strings.Contains(got, "eve") || !strings.Contains(got, "promptscript") { + t.Fatalf("supported targets omit no-global upstream agents: %s", got) + } + + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "autohand-home"), "CLAUDE_CONFIG_DIR": filepath.Join(home, "claude-home"), + "CODEX_HOME": filepath.Join(home, "codex-home"), "GROK_HOME": filepath.Join(home, "grok-home"), + "HERMES_HOME": filepath.Join(home, "hermes-home"), "VIBE_HOME": filepath.Join(home, "vibe-home"), + "XDG_CONFIG_HOME": filepath.Join(home, "xdg"), + } + testseam.Swap(t, &skillSetupGetenv, func(name string) string { return custom[name] }) + customCases := map[string]string{ + "autohand-code": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(custom["CODEX_HOME"], "skills"), + "grok": filepath.Join(custom["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(custom["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(custom["VIBE_HOME"], "skills"), + "amp": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "replit": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "universal": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + "crush": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + "devin": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + "goose": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + "kimchi": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + "opencode": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for target, want := range customCases { + if got := resolveSkillSetupBase(home, target); !sameSkillSetupPath(got, want) { + t.Errorf("custom %s root = %q, want %q", target, got, want) + } + } + for _, target := range []string{"codex", "amp", "opencode"} { + if !isUniversalSkillSetupBase(resolveSkillSetupBase(home, target)) { + t.Errorf("custom %s root not classified universal", target) + } + } +} + +func TestCrossPlatformCoverageOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSetupBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageSkillSetupWindowsPathNormalization(t *testing.T) { + testseam.Swap(t, &skillSetupFoldPathCase, true) + if !sameSkillSetupPath(filepath.Join("Root", "Skills"), filepath.Join("root", "skills")) { + t.Fatal("case-insensitive platform path normalization failed") + } +} diff --git a/internal/app/skill_setup_cleanup_coverage_test.go b/internal/app/skill_setup_cleanup_coverage_test.go index 957496d4..b346eb3d 100644 --- a/internal/app/skill_setup_cleanup_coverage_test.go +++ b/internal/app/skill_setup_cleanup_coverage_test.go @@ -15,6 +15,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillprovenance" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/skillstate" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" ) func useManagedSkillNames(t *testing.T, names ...string) { @@ -336,12 +337,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailuresRestoreOldSet(t *test return originalBackup(homeDir, dir) }) } else { - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if newPath == second && strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) } @@ -432,8 +433,8 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("restore failure aggregation", func(t *testing.T) { t.Run("remove published", func(t *testing.T) { - testseam.Swap(t, &skillSetupRemoveAll, func(string) error { return failure }) - if err := restoreSkillSetupTarget([]string{"published"}, nil); !errors.Is(err, failure) { + testseam.Swap(t, &skillSetupRollbackPaths, func([]upgrade.SkillPathPublication) error { return failure }) + if err := restoreSkillSetupTarget([]upgrade.SkillPathPublication{{Destination: "published"}}, nil); !errors.Is(err, failure) { t.Fatalf("remove published error = %v", err) } }) @@ -445,14 +446,14 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("stat", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "检查 Skill 恢复目标失败") { t.Fatalf("restore stat error = %v", err) } }) t.Run("mkdir", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "创建 Skill 恢复目录失败") { @@ -460,9 +461,9 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } }) t.Run("rename", func(t *testing.T) { - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) err := restoreSkillSetupTarget(nil, []skillSetupBackedUpDir{{original: "original", backup: "backup"}}) if !errors.Is(err, failure) || !strings.Contains(err.Error(), "恢复原 Skill 失败") { t.Fatalf("restore rename error = %v", err) @@ -479,10 +480,10 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { } return "", failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) restoreErr := errors.New("restore failure") - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return restoreErr }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return restoreErr }) _, err := backupSkillSetupTarget("home", []skillSetupBackup{{Path: "first"}, {Path: "second"}}, io.Discard) if !errors.Is(err, failure) || !errors.Is(err, restoreErr) { t.Fatalf("backup rollback error = %v", err) @@ -490,8 +491,11 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { }) t.Run("publish rollback failure", func(t *testing.T) { - testseam.Swap(t, &skillSetupPublishRename, func(string, string) error { return failure }) - testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupPublishPath, func(string, string) (upgrade.SkillPathPublication, error) { + return upgrade.SkillPathPublication{}, failure + }) + testseam.Swap(t, &skillSetupRestoreBackup, func(string, string) error { return failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupMkdirAll, func(string, os.FileMode) error { return nil }) err := publishSkillSetupTarget( []skillSetupStagedDir{{staged: "staged", dest: "dest"}}, @@ -530,12 +534,12 @@ func TestCrossPlatformCoverageSkillSetupTransactionFailureEdges(t *testing.T) { t.Run("after publish failure", func(t *testing.T) { plan := newPlan(t) - originalRename := skillSetupPublishRename - testseam.Swap(t, &skillSetupPublishRename, func(oldPath, newPath string) error { + originalPublish := skillSetupPublishPath + testseam.Swap(t, &skillSetupPublishPath, func(oldPath, newPath string) (upgrade.SkillPathPublication, error) { if strings.HasPrefix(filepath.Base(filepath.Dir(oldPath)), ".dws-setup-set-") { - return failure + return upgrade.SkillPathPublication{}, failure } - return originalRename(oldPath, newPath) + return originalPublish(oldPath, newPath) }) originalRemoveAll := skillSetupRemoveAll cleanupErr := errors.New("cleanup after publish failure") diff --git a/internal/app/skill_setup_event_migration_universal_test.go b/internal/app/skill_setup_event_migration_universal_test.go new file mode 100644 index 00000000..928f672d --- /dev/null +++ b/internal/app/skill_setup_event_migration_universal_test.go @@ -0,0 +1,139 @@ +package app + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies +// pins the P0 fix: when a beta.6 folded dingtalk-misc (carrying the personal +// Event route) exists only in a UNIVERSAL agent home (~/.codex/skills), the +// Event/misc migration must not leave physical duplicates there. Universal +// homes read ~/.agents/skills directly, so their old folded copies are retired +// (backed up) and the split standalone event + clean misc land in canonical. +func TestCrossPlatformCoverageSkillSetupEventMigrationRetiresUniversalFoldedCopies(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + // beta.6 physical copies alongside the folded misc. + for _, name := range []string{multiEventSkill, multiSharedSkill} { + if err := os.MkdirAll(filepath.Join(codexSkills, name), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codexSkills, name, "SKILL.md"), []byte("beta6 "+name+"\n"), 0o644); err != nil { + t.Fatal(err) + } + } + canonical := filepath.Join(home, ".agents", "skills") + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes") + if err != nil { + t.Fatalf("setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) + } + + // P0: the universal home must not retain any physical dingtalk-* copy. + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(codexSkills, name)); !os.IsNotExist(err) { + t.Fatalf("universal .codex/skills still has physical %s (stat err=%v)", name, err) + } + } + // canonical owns the new standalone event + clean misc (+ shared). + for _, name := range []string{multiEventSkill, multiMiscSkill, multiSharedSkill} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical missing %s: %v", name, err) + } + } + if err := validateCleanEventMiscRoot(filepath.Join(canonical, multiMiscSkill)); err != nil { + t.Fatalf("canonical misc still contains folded Event content: %v", err) + } + if _, _, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "event", "--yes"); err != nil { + t.Fatalf("idempotent rerun failed: %v", err) + } +} + +func TestCrossPlatformCoverageSkillSetupUnrelatedSelectionPreservesUniversalFoldedPair(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + canonical := filepath.Join(home, ".agents", "skills") + codexSkills := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codexSkills) + writeOldStandaloneEvent(t, codexSkills) + chat := filepath.Join(codexSkills, "dingtalk-chat") + if err := os.MkdirAll(chat, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(chat, "SKILL.md"), []byte("keep chat\n"), 0o644); err != nil { + t.Fatal(err) + } + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill, "dingtalk-doc"}) + stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{canonical, codexSkills}, "--skill", "doc", "--yes") + if err != nil { + t.Fatalf("selective doc install failed: %v\nstdout=%s\nstderr=%s", err, stdout, stderr) + } + if strings.Contains(stdout, "Event 原子迁移") { + t.Fatalf("unrelated selection migrated Event/misc: %s", stdout) + } + assertOldEventMiscPair(t, codexSkills) + if body, err := os.ReadFile(filepath.Join(chat, "SKILL.md")); err != nil || string(body) != "keep chat\n" { + t.Fatalf("unselected chat changed: body=%q err=%v", body, err) + } +} + +func TestCrossPlatformCoverageSkillSetupUniversalRetirementFailures(t *testing.T) { + failure := errors.New("retirement denied") + + t.Run("home", func(t *testing.T) { + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) + codex := filepath.Join(t.TempDir(), ".codex", "skills") + if err := retireMigratedUniversalSkills([]string{codex}, []string{multiEventSkill}, io.Discard); !errors.Is(err, failure) { + t.Fatalf("home failure = %v, want %v", err, failure) + } + }) + + t.Run("deduplicate", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + if err := retireMigratedUniversalSkills( + []string{codex, codex}, + []string{multiEventSkill, multiEventSkill}, + io.Discard, + ); err != nil { + t.Fatalf("deduplicated retirement failed: %v", err) + } + }) + + // Retiring an obsolete universal copy installs nothing, so its failure is + // surfaced as a warning and the successful installation is kept. + t.Run("backup failure warns without failing the command", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return home, nil }) + codex := filepath.Join(home, ".codex", "skills") + writeFoldedEventMisc(t, codex) + src := writeMultiSkillSource(t, []string{multiEventSkill, multiSharedSkill, multiMiscSkill}) + testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { + return "", failure + }) + _, stderr, err := executeMultiSkillSetupTest( + t, + src, + []string{filepath.Join(home, ".agents", "skills"), codex}, + "--skill", "event", "--yes", + ) + if err != nil { + t.Fatalf("retirement backup failure must not fail the command: %v", err) + } + if !strings.Contains(stderr, "退役 universal Agent") { + t.Fatalf("retirement backup failure must be reported, stderr = %q", stderr) + } + }) +} diff --git a/internal/app/skill_setup_full_coverage_test.go b/internal/app/skill_setup_full_coverage_test.go index 02da441f..8ead0740 100644 --- a/internal/app/skill_setup_full_coverage_test.go +++ b/internal/app/skill_setup_full_coverage_test.go @@ -523,15 +523,45 @@ func TestCrossPlatformCoverageSkillSetupEventMigrationFailureBranches(t *testing } }) - t.Run("ordinary and prerequisite install errors", func(t *testing.T) { + t.Run("ordinary install error", func(t *testing.T) { + src := writeMultiSkillSource(t, []string{multiEventSkill, multiMiscSkill}) + copyCalls := 0 + testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return fail }) + migration := filepath.Join(t.TempDir(), "migration") + ordinary := filepath.Join(t.TempDir(), "ordinary") + if _, _, err := installMultiSkillsWithEventMigration(src, []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); err == nil || !strings.Contains(err.Error(), "未执行迁移") { + t.Fatalf("ordinary install failure = %v", err) + } + if copyCalls == 0 { + t.Fatal("ordinary staging failure seam was not exercised") + } + }) + + t.Run("canonical ordinary install error", func(t *testing.T) { + testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { + return 0, 0, fail + }) + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + universalMigration := filepath.Join(home, ".codex", "skills") + if _, _, err := installMultiSkillsWithEventMigration( + "src", + []string{multiEventSkill}, + []string{canonical, universalMigration}, + []string{universalMigration}, + true, + io.Discard, + io.Discard, + ); !errors.Is(err, fail) { + t.Fatalf("canonical ordinary install failure = %v, want %v", err, fail) + } + }) + + t.Run("prerequisite install error", func(t *testing.T) { testseam.Swap(t, &skillSetupInstallMulti, func(string, []string, []string, io.Writer, io.Writer, bool) (int, int, error) { return 0, 0, fail }) migration := filepath.Join(t.TempDir(), "migration") - ordinary := filepath.Join(t.TempDir(), "ordinary") - if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill}, []string{migration, ordinary}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { - t.Fatalf("ordinary install failure = %v", err) - } if _, _, err := installMultiSkillsWithEventMigration("src", []string{multiEventSkill, multiMiscSkill, multiSharedSkill}, []string{migration}, []string{migration}, true, io.Discard, io.Discard); !errors.Is(err, fail) { t.Fatalf("prerequisite install failure = %v", err) } diff --git a/internal/app/skill_setup_plan_test.go b/internal/app/skill_setup_plan_test.go index fcc5ceb0..90b13869 100644 --- a/internal/app/skill_setup_plan_test.go +++ b/internal/app/skill_setup_plan_test.go @@ -48,16 +48,13 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t backupCalls, copyCalls := []string{}, 0 testseam.Swap(t, &skillSetupBackupAndRemove, func(_ string, path string) (string, error) { backupCalls = append(backupCalls, path) + if err := os.RemoveAll(path); err != nil { + return "", err + } return "backup", nil }) testseam.Swap(t, &skillSetupCopyDir, func(string, string) error { copyCalls++; return nil }) testseam.Swap(t, &skillSetupWriteFile, func(string, []byte, os.FileMode) error { return nil }) - testseam.Swap(t, &skillSetupPublishRename, func(src, dest string) error { - if err := os.RemoveAll(dest); err != nil { - return err - } - return os.Rename(src, dest) - }) dryRunCmd := skillSetupCoverageCommand(t, skillSetupModeMulti, false) var dryRunOut bytes.Buffer dryRunCmd.SetOut(&dryRunOut) @@ -119,6 +116,9 @@ func TestCrossPlatformCoverageSkillSetupPlanPreviewDeclineAndExecutionMatch(t *t // A filtered multi plan replaces only selected same-name skills and leaves // unselected siblings out of the backup set. + if err := os.MkdirAll(filepath.Join(dest, "dws"), 0o755); err != nil { + t.Fatal(err) + } filtered, err := buildSkillSetupPlan(skillSetupModeMulti, source, []string{dest}, []string{"dingtalk-a"}, true) if err != nil { t.Fatal(err) @@ -149,55 +149,38 @@ func TestCrossPlatformCoverageSkillSetupMonoPlanIncludesSameNameTarget(t *testin func TestCrossPlatformCoverageSkillSetupGenericCleanupDerivesHomeFromConcreteTarget(t *testing.T) { home := t.TempDir() dest := filepath.Join(home, ".codex", "skills") - genericMono := filepath.Join(home, ".agents", "skills", "dws") - if err := os.MkdirAll(genericMono, 0o755); err != nil { + canonical := filepath.Join(home, ".agents", "skills") + oldCodex := filepath.Join(dest, "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { t.Fatal(err) } testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", errors.New("transient HOME failure") }) - plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true) + plan, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{canonical, dest}, []string{"dingtalk-chat"}, true) if err != nil { t.Fatal(err) } - if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != filepath.Dir(genericMono) { - t.Fatalf("generic cleanup target = %#v", plan.Targets) + if len(plan.Targets) != 2 || !plan.Targets[1].CleanupOnly || plan.Targets[1].Destination != dest { + t.Fatalf("universal cleanup target = %#v", plan.Targets) } - if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != genericMono { - t.Fatalf("generic cleanup backups = %#v", plan.Targets[1].Backups) + if len(plan.Targets[1].Backups) != 1 || plan.Targets[1].Backups[0].Path != oldCodex { + t.Fatalf("universal cleanup backups = %#v", plan.Targets[1].Backups) } var preview bytes.Buffer renderSkillSetupPlan(&preview, plan) - if !strings.Contains(preview.String(), "仅迁移旧的通用 DWS 副本") { - t.Fatalf("generic cleanup preview missing: %s", preview.String()) + if !strings.Contains(preview.String(), "改用统一安装位置") { + t.Fatalf("universal cleanup preview missing: %s", preview.String()) } - t.Run("managed multi and scan failure", func(t *testing.T) { - managedDir := filepath.Join(home, ".agents", "skills", "dingtalk-chat") - if err := os.MkdirAll(managedDir, 0o755); err != nil { - t.Fatal(err) - } - target, targetErr := genericSkillCleanupTarget([]string{dest}, map[string]bool{"dingtalk-chat": true}) - if targetErr != nil || target == nil || len(target.Backups) != 2 { - t.Fatalf("managed generic cleanup = %#v, %v", target, targetErr) - } - failure := errors.New("generic scan failure") - testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) - if _, targetErr := genericSkillCleanupTarget([]string{dest}, nil); !errors.Is(targetErr, failure) { - t.Fatalf("generic scan error = %v", targetErr) - } - if _, planErr := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-chat"}, true); !errors.Is(planErr, failure) { - t.Fatalf("generic cleanup plan error = %v", planErr) - } - }) } func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing.T) { failure := errors.New("cleanup failure") cleanup := skillSetupTargetPlan{Destination: "generic", CleanupOnly: true, Backups: []skillSetupBackup{{Path: "old"}}} - t.Run("prior skip suppresses cleanup", func(t *testing.T) { + t.Run("cleanup runs even after an install skip", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return t.TempDir(), nil }) plan := &skillSetupPlan{Mode: skillSetupModeMono, Source: "missing", Targets: []skillSetupTargetPlan{{Destination: "install"}, cleanup}} installed, skipped, err := executeSkillSetupPlan(plan, io.Discard, io.Discard) @@ -206,11 +189,14 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. } }) + // A cleanup-only target installs nothing, so its failure is a warning and + // must never increment skipped — runSkillSetup turns any skipped count into + // a hard error and would fail an otherwise complete installation. t.Run("home failure keeps generic copy", func(t *testing.T) { testseam.Swap(t, &skillSetupUserHomeDir, func() (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "保留通用 Skill 副本") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "保留 universal Agent 旧副本") { t.Fatalf("cleanup HOME failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -220,7 +206,7 @@ func TestCrossPlatformCoverageSkillSetupCleanupOnlyExecutionBranches(t *testing. testseam.Swap(t, &skillSetupBackupAndRemove, func(string, string) (string, error) { return "", failure }) var stderr bytes.Buffer _, skipped, err := executeSkillSetupPlan(&skillSetupPlan{Mode: skillSetupModeMono, Targets: []skillSetupTargetPlan{cleanup}}, io.Discard, &stderr) - if err != nil || skipped != 1 || !strings.Contains(stderr.String(), "迁移失败") { + if err != nil || skipped != 0 || !strings.Contains(stderr.String(), "迁移失败") { t.Fatalf("cleanup backup failure = (%d, %v, %q)", skipped, err, stderr.String()) } }) @@ -247,11 +233,13 @@ func TestCrossPlatformCoverageSkillSetupPlanDeduplicatesAndFailsClosed(t *testin t.Fatal(err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, failure }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMono, "source", []string{monoDest}, nil, false); err == nil || !strings.Contains(err.Error(), "\u68c0\u67e5\u5c06\u88ab\u66ff\u6362") { t.Fatalf("replacement stat error = %v", err) } testseam.Swap(t, &skillSetupStat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) + testseam.Swap(t, &skillSetupLstat, func(string) (os.FileInfo, error) { return nil, os.ErrNotExist }) testseam.Swap(t, &skillSetupReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) if _, err := buildSkillSetupPlan(skillSetupModeMulti, "source", []string{dest}, []string{"dingtalk-a"}, false); err == nil || !strings.Contains(err.Error(), "\u626b\u63cf\u8fc7\u671f") { t.Fatalf("stale scan error = %v", err) diff --git a/internal/app/skill_setup_publication_race_test.go b/internal/app/skill_setup_publication_race_test.go new file mode 100644 index 00000000..8d954e3a --- /dev/null +++ b/internal/app/skill_setup_publication_race_test.go @@ -0,0 +1,85 @@ +package app + +import ( + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade" +) + +func TestCrossPlatformCoverageSkillSetupRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + writeSkillSetupFile(t, first, "old first") + writeSkillSetupFile(t, second, "old second") + backups, err := backupSkillSetupTarget(home, []skillSetupBackup{{Path: first}, {Path: second}}, io.Discard) + if err != nil { + t.Fatal(err) + } + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + writeSkillSetupFile(t, stagedFirst, "new first") + writeSkillSetupFile(t, stagedSecond, "new second") + + failure := errors.New("injected second setup publication failure") + originalPublish := skillSetupPublishPath + calls := 0 + testseam.Swap(t, &skillSetupPublishPath, func(staged, destination string) (upgrade.SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + writeSkillSetupFile(t, first, "concurrent") + return upgrade.SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err = publishSkillSetupTarget([]skillSetupStagedDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, backups) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("setup transaction error = %v", err) + } + assertSkillSetupFile(t, first, "concurrent") + assertSkillSetupFile(t, second, "old second") + var firstBackup string + for _, item := range backups { + if item.original == first { + firstBackup = item.backup + break + } + } + if firstBackup == "" { + t.Fatal("first backup was not recorded") + } + assertSkillSetupFile(t, firstBackup, "old first") +} + +func writeSkillSetupFile(t *testing.T, dir, content string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "SKILL.md"), []byte(content), 0o644); err != nil { + t.Fatal(err) + } +} + +func assertSkillSetupFile(t *testing.T, dir, want string) { + t.Helper() + content, err := os.ReadFile(filepath.Join(dir, "SKILL.md")) + if err != nil || string(content) != want { + t.Fatalf("Skill content at %s = %q, %v; want %q", dir, content, err, want) + } +} diff --git a/internal/app/skill_setup_test.go b/internal/app/skill_setup_test.go index 2a776355..f5e3eb28 100644 --- a/internal/app/skill_setup_test.go +++ b/internal/app/skill_setup_test.go @@ -297,12 +297,12 @@ func TestResolveSkillSetupTargetsSingleAgent(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills", "dws") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -321,12 +321,12 @@ func TestResolveSkillSetupTargetsMultiOmitsDwsTail(t *testing.T) { if err != nil { t.Fatalf("unexpected err: %v", err) } - if len(got) != 1 { - t.Fatalf("expected 1 dest, got %d", len(got)) + if len(got) != 2 { + t.Fatalf("expected canonical + Claude, got %d", len(got)) } want := filepath.Join(home, ".claude", "skills") - if filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("expected %s, got %s", want, got[0]) + if filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("expected %s, got %s", want, got[1]) } } @@ -343,8 +343,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsPrefersSpecificAgentRoot(t t.Fatal(err) } want := filepath.Join(home, ".codex", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } } @@ -360,8 +360,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } want := filepath.Join(home, ".zcode", "skills") - if len(got) != 1 || filepath.Clean(got[0]) != filepath.Clean(want) { - t.Fatalf("targets = %v, want [%s]", got, want) + if len(got) != 2 || filepath.Clean(got[1]) != filepath.Clean(want) { + t.Fatalf("targets = %v, want canonical + %s", got, want) } explicit, err := resolveSkillSetupTargets("zcode", skillSetupModeMono) @@ -369,8 +369,8 @@ func TestCrossPlatformCoverageResolveSkillSetupTargetsDetectsZCode(t *testing.T) t.Fatal(err) } wantMono := filepath.Join(want, "dws") - if len(explicit) != 1 || filepath.Clean(explicit[0]) != filepath.Clean(wantMono) { - t.Fatalf("explicit zcode targets = %v, want [%s]", explicit, wantMono) + if len(explicit) != 2 || filepath.Clean(explicit[1]) != filepath.Clean(wantMono) { + t.Fatalf("explicit zcode targets = %v, want canonical + %s", explicit, wantMono) } } @@ -1055,12 +1055,11 @@ func TestCrossPlatformCoverageSkillSetupSelectiveEventMigratesOnlyFoldedTargets( if err := os.WriteFile(filepath.Join(foldedHome, "dingtalk-chat", "SKILL.md"), []byte("keep sibling\n"), 0o644); err != nil { t.Fatal(err) } - stdout, stderr, err := executeMultiSkillSetupTest(t, src, []string{freshHome, foldedHome}, "--skill", "event", "--yes") if err != nil { t.Fatalf("selective event setup failed: %v\nstderr=%s\nstdout=%s", err, stderr, stdout) } - if !strings.Contains(stdout, "重新加载 Skills") { + if !strings.Contains(stdout, "请重启已打开的 Agent") { t.Fatalf("completion should tell the user to reload skills: %s", stdout) } diff --git a/internal/app/upgrade.go b/internal/app/upgrade.go index 6388ce46..5c11dd5d 100644 --- a/internal/app/upgrade.go +++ b/internal/app/upgrade.go @@ -620,6 +620,9 @@ func runUpgrade(ctx context.Context, opts upgradeOptions) error { for _, d := range succeeded { fmt.Printf(" %s %s\n", ugDim("→"), ugCyan(shortenHome(d.Dir))) } + for _, d := range result.RetireWarnings() { + fmt.Printf(" %s %s %s\n", ugYellow("⚠"), shortenHome(d.Dir), ugDim("旧副本未能迁移,可手动删除: "+d.Err.Error())) + } } else { fmt.Printf(" %s\n", ugGreen("✓")) } diff --git a/internal/app/upgrade_full_coverage_test.go b/internal/app/upgrade_full_coverage_test.go index ae777a9d..b96f5b7d 100644 --- a/internal/app/upgrade_full_coverage_test.go +++ b/internal/app/upgrade_full_coverage_test.go @@ -292,6 +292,12 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { if stage == "install-failed-dir" { return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/failed", Status: upgradepkg.SkillDirFailed, Err: fail}}}, nil } + if stage == "install-retire-warning" { + return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{ + {Dir: "/ok", Status: upgradepkg.SkillDirOK}, + {Dir: "/stale", Status: upgradepkg.SkillDirRetireWarning, Err: errors.New("retirement refused")}, + }}, nil + } return &upgradepkg.SkillUpgradeResult{Results: []upgradepkg.SkillDirResult{{Dir: "/ok", Status: upgradepkg.SkillDirOK}}}, nil } } @@ -300,7 +306,7 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { "ensure", "tag-error", "latest-error", "not-needed", "cancel", "find-binary", "temp-fallback", "temp-both", "backup", "checksum-download", "checksum-read", "binary-download", "skills-download", "verify-binary", "verify-skills", "extract-binary", "extract-tar", "binary-missing", "validate", "extract-skills", "skill-missing", "replace", "install", "install-failed-dir", - "success", "success-no-skills", + "success", "success-no-skills", "install-retire-warning", } { t.Run(stage, func(t *testing.T) { configure(stage) @@ -330,14 +336,14 @@ func TestCrossPlatformCoverageRunUpgradeAllStagesCoverage(t *testing.T) { opts.skipSkills = true } err := runUpgrade(context.Background(), opts) - wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" + wantError := stage != "not-needed" && stage != "cancel" && stage != "backup" && stage != "checksum-download" && stage != "checksum-read" && stage != "success" && stage != "success-no-skills" && stage != "install-retire-warning" if wantError && err == nil { t.Fatalf("stage %s succeeded", stage) } if !wantError && err != nil { t.Fatalf("stage %s failed: %v", stage, err) } - if (stage == "success" || stage == "success-no-skills") && !rb.cleaned { + if (stage == "success" || stage == "success-no-skills" || stage == "install-retire-warning") && !rb.cleaned { t.Fatal("successful upgrade did not clean backups") } if stage == "success" { diff --git a/internal/auth/oauth_helpers.go b/internal/auth/oauth_helpers.go index fe2c4728..c22a2a24 100644 --- a/internal/auth/oauth_helpers.go +++ b/internal/auth/oauth_helpers.go @@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName + updated.RepairOrganizationMirror = data.RepairOrganizationMirror if updated.CorpName == "" { updated.CorpName = data.CorpName } @@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To updated.CorpID = data.CorpID updated.UserID = data.UserID updated.UserName = data.UserName + updated.RepairOrganizationMirror = data.RepairOrganizationMirror if updated.CorpName == "" { updated.CorpName = data.CorpName } diff --git a/internal/auth/oauth_provider.go b/internal/auth/oauth_provider.go index da6b66df..02ad2380 100644 --- a/internal/auth/oauth_provider.go +++ b/internal/auth/oauth_provider.go @@ -811,7 +811,84 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) { if p.logger != nil { p.logger.Debug("refreshing token (dual-locked)") } - return oauthRefreshToken(p, ctx, data) + refreshed, rErr := oauthRefreshToken(p, ctx, data) + if rErr == nil || !isRefreshTokenRejected(rErr) { + return refreshed, rErr + } + // A stale identity slot can survive an older organization-only refresh. + // Retry once with the same-corp organization mirror while holding the + // existing dual lock; the fallback marks the publication so the rotated + // credential is written back into the mirror slot it consumed. + logging.AuthDebug( + "auth.refresh.fallback.triggered", + "corp_id", strings.TrimSpace(data.CorpID), + "user_id", strings.TrimSpace(data.UserID), + "error", rErr, + ) + fallback, fErr := p.refreshFromOrgSlot(ctx, data) + if fErr != nil { + logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr) + return nil, rErr + } + if p.logger != nil { + p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态")) + } + return fallback, nil +} + +// refreshFromOrgSlot retries a rejected refresh with the token mirrored in +// the organization slot. The mirror must match the current corp, be valid, +// and differ from the rejected token. When both slots carry user identities, +// they must agree; legacy mirrors with an empty UserID are backfilled from the +// current identity before refresh. +func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) { + if current == nil { + return nil, fmt.Errorf("no current token data") + } + corpID := strings.TrimSpace(current.CorpID) + if corpID == "" { + return nil, fmt.Errorf("current token has no corpId") + } + orgData, err := tokenLoadKeychainForCorpID(corpID) + if err != nil { + return nil, err + } + if orgData == nil { + return nil, ErrTokenDataNotFound + } + if strings.TrimSpace(orgData.CorpID) != corpID { + return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID) + } + if !orgData.IsRefreshTokenValid() { + return nil, fmt.Errorf("organization mirror refresh_token 已过期") + } + if orgData.RefreshToken == current.RefreshToken { + return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token") + } + currentUserID := strings.TrimSpace(current.UserID) + orgUserID := strings.TrimSpace(orgData.UserID) + if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID { + return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID) + } + if orgUserID == "" { + orgData.UserID = current.UserID + orgData.UserName = current.UserName + } + // The refresh below consumes the mirror's refresh_token. Mark the + // publication so persistence writes the rotated credential back into the + // organization slot even under an explicit runtime selector whose plan + // would otherwise skip it (for example a preserved unresolved sibling). + orgData.RepairOrganizationMirror = true + refreshed, err := oauthRefreshToken(p, ctx, orgData) + if err != nil { + return nil, err + } + logging.AuthDebug( + "auth.refresh.fallback.success", + "corp_id", corpID, + "new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339), + ) + return refreshed, nil } // ExchangeAuthCode takes an AuthCode and an optional UserID provided by an diff --git a/internal/auth/org_slot_refresh_fallback_test.go b/internal/auth/org_slot_refresh_fallback_test.go new file mode 100644 index 00000000..c5ab16cf --- /dev/null +++ b/internal/auth/org_slot_refresh_fallback_test.go @@ -0,0 +1,427 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auth + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) { + tests := []struct { + name string + err error + want bool + }{ + {"nil", nil, false}, + {"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true}, + {"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false}, + {"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true}, + {"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false}, + {"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false}, + {"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false}, + {"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false}, + {"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false}, + {"plain error is unknown", errors.New("boom"), false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := isRefreshTokenRejected(tt.err); got != tt.want { + t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want) + } + }) + } +} + +// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on +// and records refresh attempts plus organization slot lookups. +type orgSlotFallbackFixture struct { + provider *OAuthProvider + stale *TokenData + orgMirror *TokenData + renewed *TokenData + rejected *MCPTokenExchangeError + refreshErr error + orgRefreshErr error + refreshCalls []string + refreshUserIDs []string + orgLoads int +} + +func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture { + t.Helper() + isolateOAuthPersistence(t) + + f := &orgSlotFallbackFixture{ + provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard}, + stale: &TokenData{ + AccessToken: "old-access", + RefreshToken: "stale-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + orgMirror: &TokenData{ + AccessToken: "org-access", + RefreshToken: "org-refresh", + ExpiresAt: time.Now().Add(-time.Minute), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + renewed: &TokenData{ + AccessToken: "new-access", + RefreshToken: "new-refresh", + ExpiresAt: time.Now().Add(time.Hour), + RefreshExpAt: time.Now().Add(24 * time.Hour), + CorpID: "corp-1", + UserID: "user-1", + }, + rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, + } + f.refreshErr = f.rejected + + testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil }) + testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) }) + testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil }) + testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) { + f.refreshCalls = append(f.refreshCalls, data.RefreshToken) + f.refreshUserIDs = append(f.refreshUserIDs, data.UserID) + switch data.RefreshToken { + case "stale-refresh": + return nil, f.refreshErr + case "org-refresh": + return f.renewed, f.orgRefreshErr + } + return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken) + }) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) { + f.orgLoads++ + if corpID != "corp-1" { + return nil, ErrTokenDataNotFound + } + return f.orgMirror, nil + }) + return f +} + +func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + + got, err := f.provider.lockedRefresh(context.Background()) + if err != nil || got != f.renewed { + t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err) + } + if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" { + t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls) + } + if f.orgLoads != 1 { + t.Fatalf("organization slot loads = %d, want 1", f.orgLoads) + } +} + +func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) { + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "legacy", CorpID: "corp-1", UserID: ""}, + {Name: "user-1", CorpID: "corp-1", UserID: "user-1"}, + }, + } + selector := profileSelector("corp-1", "user-1") + + without := &TokenData{CorpID: "corp-1", UserID: "user-1"} + if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization { + t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false") + } + + with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true} + if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization { + t.Fatalf("repair marker did not force the organization slot write") + } +} + +func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) { + isolateOAuthPersistence(t) + t.Setenv("DWS_CLIENT_ID", "") + t.Setenv("DWS_CLIENT_SECRET", "") + + // Fake MCP refresh endpoint: the first call rejects the stale identity + // refresh_token with the reviewed business code; the second call (the + // organization mirror) succeeds and returns a rotated credential. + var refreshCalls atomic.Int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if refreshCalls.Add(1) == 1 { + fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`) + return + } + fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`) + })) + defer srv.Close() + + configDir := setupMCPConfigDir(t, srv.URL) + resetAppConfigCache() + + // Seed the pre-fallback state: an identity slot whose refresh_token the + // server rejects, plus a legacy organization mirror (no userId) with a + // still-valid refresh_token and a preserved unresolved sibling profile so + // an explicit --profile refresh would normally skip the org slot. + cfg := &ProfilesConfig{ + Version: profilesVersion, + Profiles: []Profile{ + {Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"}, + {Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"}, + }, + } + if err := SaveProfiles(configDir, cfg); err != nil { + t.Fatalf("SaveProfiles() error = %v", err) + } + orgMirror := &TokenData{ + AccessToken: "org-access", + RefreshToken: "org-refresh", + ExpiresAt: time.Now().Add(-time.Minute), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + Source: "mcp", + ClientID: "mcp-client", + } + if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil { + t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err) + } + staleIdentity := &TokenData{ + AccessToken: "stale-access", + RefreshToken: "stale-refresh", + ExpiresAt: time.Now().Add(-time.Hour), + RefreshExpAt: time.Now().Add(time.Hour), + CorpID: "corp-1", + UserID: "user-1", + UserName: "User One", + Source: "mcp", + ClientID: "mcp-client", + } + if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil { + t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err) + } + + SetRuntimeProfile("corp-1:user-1") + t.Cleanup(func() { SetRuntimeProfile("") }) + + p := &OAuthProvider{ + configDir: configDir, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + Output: io.Discard, + httpClient: srv.Client(), + } + got, err := p.lockedRefresh(context.Background()) + if err != nil { + t.Fatalf("lockedRefresh() error = %v", err) + } + if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" { + t.Fatalf("lockedRefresh() = %#v, want rotated credential", got) + } + if refreshCalls.Load() != 2 { + t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load()) + } + + // The fallback consumed the mirror's refresh_token: both persisted slots + // must now carry the rotated credential instead of the consumed one. + orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1") + if err != nil { + t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err) + } + if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" { + t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot) + } + identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1") + if err != nil { + t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err) + } + if identitySlot.RefreshToken != "new-refresh" { + t.Fatalf("identity slot = %#v, want new-refresh", identitySlot) + } +} + +func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) { + t.Run("transient failure does not fall back", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError} + + _, err := f.provider.lockedRefresh(context.Background()) + if err == nil || err.Error() != f.refreshErr.Error() { + t.Fatalf("lockedRefresh() error = %v, want transient failure", err) + } + if f.orgLoads != 0 { + t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("missing org slot preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { + f.orgLoads++ + return nil, ErrTokenDataNotFound + }) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("nil org data from keychain preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { + f.orgLoads++ + return nil, nil + }) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("org slot refresh failure preserves rejection", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgRefreshErr = fmt.Errorf("org mirror refresh failed") + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 2 { + t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls) + } + }) + + t.Run("different user in org slot is rejected", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.UserID = "user-2" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls) + } + }) + + t.Run("empty org slot user identity is backfilled", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.UserID = "" + f.orgMirror.UserName = "" + + got, err := f.provider.lockedRefresh(context.Background()) + if err != nil || got != f.renewed { + t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err) + } + if len(f.refreshCalls) != 2 { + t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls) + } + if f.refreshUserIDs[1] != "user-1" { + t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1]) + } + }) + + t.Run("same rejected refresh token is skipped", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.RefreshToken = "stale-refresh" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls) + } + }) + + t.Run("expired org refresh token is skipped", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour) + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) + + t.Run("missing corp id skips fallback", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.stale.CorpID = "" + + _, err := f.provider.lockedRefresh(context.Background()) + if !errors.Is(err, f.rejected) { + t.Fatalf("lockedRefresh() error = %v, want original rejection", err) + } + if f.orgLoads != 0 { + t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads) + } + }) + + t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest} + + _, err := f.provider.lockedRefresh(context.Background()) + if err == nil || err.Error() != f.refreshErr.Error() { + t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err) + } + if f.orgLoads != 0 { + t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads) + } + if len(f.refreshCalls) != 1 { + t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls) + } + }) +} + +func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) { + f := newOrgSlotFallbackFixture(t) + + if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil { + t.Fatal("refreshFromOrgSlot(nil) succeeded") + } + f.orgMirror.CorpID = "corp-2" + if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil { + t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded") + } + if len(f.refreshCalls) != 0 { + t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls) + } +} diff --git a/internal/auth/refresh_failure.go b/internal/auth/refresh_failure.go index 1e841894..359c4d4a 100644 --- a/internal/auth/refresh_failure.go +++ b/internal/auth/refresh_failure.go @@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass { } return RefreshFailureUnknown } + +// isRefreshTokenRejected reports whether the server returned a reviewed +// business code that definitively rejects the presented refresh_token. +// Only the reviewed MCP business code enables the organization-slot +// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no +// reviewed business code and deliberately do not trigger the fallback. +func isRefreshTokenRejected(err error) bool { + if err == nil { + return false + } + var exchangeErr *MCPTokenExchangeError + return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization() +} diff --git a/internal/auth/token.go b/internal/auth/token.go index c511b45b..90b4e090 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -107,6 +107,13 @@ type TokenData struct { // transient marker to reject ambiguous UID-less logins without breaking // legitimate refreshes of unresolved accounts. FreshAuthorization bool `json:"-"` + // RepairOrganizationMirror marks a fallback refresh that consumed the + // organization mirror's refresh_token. The regular write plan can skip the + // organization slot under an explicit runtime selector (for example when an + // unresolved sibling profile still owns it), which would strand a + // refresh_token the server has already rotated; the marker forces the + // rotated credential back into that slot. + RepairOrganizationMirror bool `json:"-"` } // tokenPersistenceWritePlan is the single source of truth for deciding which @@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct { ExistingIdentity bool UpgradesLegacyProfile bool PreserveUnresolvedOrganization bool + RepairOrganizationMirror bool WriteIdentity bool WriteOrganization bool WriteGlobal bool @@ -167,6 +175,11 @@ func planTokenPersistenceWrites( plan.PreserveUnresolvedOrganization = plan.UserID != "" && unresolvedProfileForCorp(cfg, plan.CorpID) != nil && !plan.UpgradesLegacyProfile + // A fallback refresh consumed the organization mirror's refresh_token; + // the rotated credential must go back into that slot even when the + // selector-driven plan would skip it (for example an explicit --profile + // that preserves an unresolved sibling profile's slot). + plan.RepairOrganizationMirror = data.RepairOrganizationMirror plan.WriteIdentity = plan.UserID != "" orgCurrentSelector := "" if cfg != nil { @@ -177,7 +190,8 @@ func planTokenPersistenceWrites( // reauthorization. Its organization slot must move with the newly exact // identity even when an explicit runtime selector keeps it from becoming // process-global current. - plan.WriteOrganization = plan.UserID == "" || + plan.WriteOrganization = plan.RepairOrganizationMirror || + plan.UserID == "" || plan.UpgradesLegacyProfile || (!plan.PreserveUnresolvedOrganization && (plan.MakeCurrent || @@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error { "persistence_profile", plan.PersistenceSelector, "write_identity_slot", plan.WriteIdentity, "write_org_mirror", plan.WriteOrganization, + "repair_org_mirror", plan.RepairOrganizationMirror, "write_global_mirror", plan.WriteGlobal, "publish_incoming_global", plan.MakeCurrent, ) diff --git a/internal/cli/command_path_fallbacks.json b/internal/cli/command_path_fallbacks.json index fdd58095..f2b3c930 100644 --- a/internal/cli/command_path_fallbacks.json +++ b/internal/cli/command_path_fallbacks.json @@ -191,12 +191,12 @@ "from": "oa +list-processes", "mode": "ambiguous", "candidates": [ - "oa +list-forms", - "oa +my-initiated", + "oa +search-forms", + "oa approval list-submitted", "oa approval list-initiated" ], "reviewed": true, - "review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf." + "review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes." }, { "from": "chat +conversation-detail", diff --git a/internal/cli/command_path_fallbacks_generated.go b/internal/cli/command_path_fallbacks_generated.go index 83bcae9e..00f52d50 100644 --- a/internal/cli/command_path_fallbacks_generated.go +++ b/internal/cli/command_path_fallbacks_generated.go @@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{ { From: "oa +list-processes", Mode: "ambiguous", - Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, Reviewed: true, - ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.", + ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.", }, } diff --git a/internal/cli/command_path_fallbacks_test.go b/internal/cli/command_path_fallbacks_test.go index 0a52ca9c..1129efdd 100644 --- a/internal/cli/command_path_fallbacks_test.go +++ b/internal/cli/command_path_fallbacks_test.go @@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) { "chat +send-file": {"chat +messages-send", "chat message send"}, "chat +send-image": {"chat +messages-send", "chat message send"}, "chat +send-media": {"chat +messages-send", "chat message send"}, - "oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}, + "oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}, "doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"}, "doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"}, } diff --git a/internal/cli/param_aliases_generated.go b/internal/cli/param_aliases_generated.go index ad82e6db..b7bf4f50 100644 --- a/internal/cli/param_aliases_generated.go +++ b/internal/cli/param_aliases_generated.go @@ -1773,6 +1773,439 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"at-user-ids", "staff-id", "uid", "user", "user-id", "userid"}, }, + { + CLIPath: "calendar +agenda", + Aliases: map[string]string{ + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "offset", "page", "room-id", "time"}, + }, + { + CLIPath: "calendar +attendee-list", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + }, + Blocked: []string{"acl-id", "room-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +book", + Aliases: map[string]string{ + "attendee-names": "with", + "begin": "start", + "end-time": "end", + "from": "start", + "names": "with", + "participant-names": "with", + "start-time": "start", + "subject": "title", + "summary": "title", + "to": "end", + }, + Blocked: []string{"attendees", "calendar-id", "calendar-name", "date", "end-date", "name", "open-dingtalk-ids", "participants", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +book-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "name": "query", + "q": "query", + "search": "query", + "search-word": "query", + }, + Blocked: []string{"subject", "text", "title"}, + }, + { + CLIPath: "calendar +cancel-event", + Aliases: map[string]string{ + "calendar-event-id": "event", + "event-id": "event", + "id": "event", + }, + Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "room-id"}, + }, + { + CLIPath: "calendar +conflicts", + Aliases: map[string]string{ + "day-offset": "in-days", + "days-from-today": "in-days", + }, + Blocked: []string{"days", "duration", "end", "from", "start", "to"}, + }, + { + CLIPath: "calendar +create", + Aliases: map[string]string{ + "attendee-ids": "attendees", + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "description": "desc", + "end-time": "end", + "freebusy": "free-busy", + "from": "start", + "room-id": "rooms", + "room-ids": "rooms", + "start-time": "start", + "subject": "title", + "summary": "title", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + "user-ids": "attendees", + "users": "attendees", + }, + Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "event", "event-id", "field-description", "group-id", "id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "rich-text-desc", "room", "room-name", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar +free", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "name": "who", + "person": "who", + "person-name": "who", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"date", "time", "user", "user-id", "user-ids", "users", "with"}, + }, + { + CLIPath: "calendar +free-slots", + Aliases: map[string]string{ + "day-offset": "in-days", + "days-from-today": "in-days", + "end-hour": "to", + "start-hour": "from", + }, + Blocked: []string{"days", "duration", "end", "end-time", "start", "start-time", "time-max", "time-min"}, + }, + { + CLIPath: "calendar +freebusy", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "room-id": "rooms", + "room-ids": "rooms", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "attendee-names", "date", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "staff-id", "time", "uid", "user", "user-id", "userid", "who", "with"}, + }, + { + CLIPath: "calendar +get", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + }, + Blocked: []string{"acl-id", "room-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +invite", + Aliases: map[string]string{ + "attendee-names": "with", + "calendar-event-id": "event", + "event-id": "event", + "id": "event", + "names": "with", + "participant-names": "with", + }, + Blocked: []string{"acl-id", "attendees", "calendar-book-id", "calendar-id", "open-dingtalk-ids", "participants", "room-id", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +my-free", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"date", "time"}, + }, + { + CLIPath: "calendar +reschedule", + Aliases: map[string]string{ + "begin": "start", + "calendar-event-id": "event", + "end-time": "end", + "event-id": "event", + "from": "start", + "id": "event", + "start-time": "start", + "to": "end", + }, + Blocked: []string{"acl-id", "calendar-book-id", "calendar-id", "date", "end-date", "room-id", "start-date", "time", "time-max", "time-min"}, + }, + { + CLIPath: "calendar +room-find", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "group": "group-id", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "name": "room-name", + "page-index": "page", + "page-size": "limit", + "per-page": "limit", + "query": "room-name", + "room-group-id": "group-id", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "date", "location", "next-cursor", "page-token", "room", "room-id", "room-ids", "rooms", "time"}, + }, + { + CLIPath: "calendar +room-groups", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "page-index": "page", + "page-size": "limit", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "cursor", "page-token"}, + }, + { + CLIPath: "calendar +room-search", + Aliases: map[string]string{ + "name": "room-name", + "query": "room-name", + }, + Blocked: []string{"group-id", "location", "room", "room-id", "room-ids", "rooms"}, + }, + { + CLIPath: "calendar +rsvp", + Aliases: map[string]string{ + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "event-id": "event", + "response": "status", + "response-status": "status", + }, + Blocked: []string{"acl-id", "availability", "done", "free-busy", "room-id", "state"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "calendar +search-event", + Aliases: map[string]string{ + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search": "query", + "search-word": "query", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "to": "end", + "top": "limit", + }, + Blocked: []string{"acl-id", "count", "date", "event", "event-id", "id", "name", "offset", "page", "page-index", "room-id", "subject", "text", "time", "title"}, + }, + { + CLIPath: "calendar +suggest-time", + Aliases: map[string]string{ + "attendee-names": "with", + "begin": "start", + "duration-minutes": "duration", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "meeting-duration-minutes": "duration", + "min-time": "start", + "names": "with", + "participant-names": "with", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "to": "end", + }, + Blocked: []string{"attendees", "date", "open-dingtalk-ids", "participants", "remind-minutes", "time", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "calendar +suggestion", + Aliases: map[string]string{ + "begin": "start", + "duration-minutes": "duration", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-time": "end", + "meeting-duration-minutes": "duration", + "min-time": "start", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + "user-ids": "users", + }, + Blocked: []string{"at-user-ids", "attendee-name", "attendee-names", "date", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "room-id", "room-ids", "room-name", "rooms", "staff-id", "time", "uid", "user", "user-id", "userid", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar +update", + Aliases: map[string]string{ + "add-user-ids": "add-attendees", + "add-users": "add-attendees", + "begin": "start", + "calendar": "calendar-id", + "calendar-book-id": "calendar-id", + "calendar-event-id": "event", + "description": "desc", + "end-time": "end", + "event-id": "event", + "freebusy": "free-busy", + "from": "start", + "remove-user-ids": "remove-attendees", + "remove-users": "remove-attendees", + "start-time": "start", + "subject": "title", + "summary": "title", + "time-zone": "timezone", + "to": "end", + "tz": "timezone", + }, + Blocked: []string{"acl-id", "attendee-name", "attendee-names", "calendar-name", "config", "date", "end-date", "field-description", "group-id", "locale", "name", "offset", "open-dingtalk-ids", "participant-name", "participant-names", "remind-minutes", "reminder-minutes", "rich-text-desc", "room-id", "room-ids", "room-name", "rooms", "start-date", "time", "time-max", "time-min", "utc-offset", "who", "with"}, + Ambiguous: []string{"attendees", "id", "user-ids", "users"}, + }, + { + CLIPath: "calendar acl delete", + Blocked: []string{"calendar-book-id", "calendar-id", "event", "event-id", "room-id", "user-id"}, + }, + { + CLIPath: "calendar attachment add", + Blocked: []string{"attachments", "file", "file-id", "file-ids"}, + }, + { + CLIPath: "calendar attendee add", + Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"}, + }, + { + CLIPath: "calendar attendee delete", + Blocked: []string{"attendee-name", "attendee-names", "open-dingtalk-ids", "participant-name", "participant-names", "who", "with"}, + }, + { + CLIPath: "calendar busy search", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"attendee-name", "attendee-names", "group-id", "location", "name", "names", "participant-names", "room", "room-name", "who", "with"}, + }, + { + CLIPath: "calendar event create", + Aliases: map[string]string{ + "reminder-minutes": "remind-minutes", + "reminder-offset-minutes": "remind-minutes", + "room-id": "rooms", + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"at", "attendee-name", "attendee-names", "due", "duration", "group-id", "locale", "offset", "participant-name", "participant-names", "remind-at", "reminder-time", "room", "room-name", "utc-offset", "who", "with"}, + }, { CLIPath: "calendar event list", Aliases: map[string]string{ @@ -1789,6 +2222,54 @@ var generatedParamAliases = []ParamAliasEntry{ }, Blocked: []string{"offset", "page", "time"}, }, + { + CLIPath: "calendar event respond", + Aliases: map[string]string{ + "response": "status", + "response-status": "status", + }, + Blocked: []string{"availability", "done", "free-busy", "state"}, + }, + { + CLIPath: "calendar event suggest", + Aliases: map[string]string{ + "duration-minutes": "duration", + "meeting-duration-minutes": "duration", + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"attendee-name", "attendee-names", "from", "locale", "name", "names", "offset", "open-dingtalk-ids", "participant-names", "remind-minutes", "to", "utc-offset", "who", "with"}, + }, + { + CLIPath: "calendar event update", + Aliases: map[string]string{ + "time-zone": "timezone", + "tz": "timezone", + }, + Blocked: []string{"attendees", "group-id", "locale", "offset", "participants", "remind-minutes", "reminder-minutes", "room-id", "room-ids", "room-name", "rooms", "utc-offset"}, + }, + { + CLIPath: "calendar room add", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"group-id", "location", "room", "room-name"}, + }, + { + CLIPath: "calendar room delete", + Aliases: map[string]string{ + "room-id": "rooms", + }, + Blocked: []string{"group-id", "location", "room", "room-name"}, + }, + { + CLIPath: "calendar room search", + Aliases: map[string]string{ + "group": "group-id", + "room-group-id": "group-id", + }, + Blocked: []string{"location", "room", "room-id", "room-ids", "rooms"}, + }, { CLIPath: "chat +bot-find", Aliases: map[string]string{ @@ -2941,7 +3422,7 @@ var generatedParamAliases = []ParamAliasEntry{ "uid": "user", "userid": "user", }, - Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "role-id", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, + Blocked: []string{"at-user-ids", "conversation-ids", "dest-conversation-id", "group-id", "group-ids", "group-name", "name", "open-conversation-ids", "source", "src-conversation-id", "target", "to-user", "user-ids", "users"}, }, { CLIPath: "chat group-role update", @@ -5572,8 +6053,560 @@ var generatedParamAliases = []ParamAliasEntry{ Blocked: []string{"name", "subject", "text", "title"}, }, { - CLIPath: "oa +list-executed", + CLIPath: "minutes +action-items", Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +apply-permission", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "policy", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +detail", + Aliases: map[string]string{ + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-token": "cursor", + "task-uuid": "id", + "task-uuids": "ids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "offset", "page", "record-id", "task-id", "url"}, + }, + { + CLIPath: "minutes +download", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "record-id", "task-id", "url"}, + Ambiguous: []string{"destination", "path", "save-path"}, + }, + { + CLIPath: "minutes +export-pack", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +latest", + Aliases: map[string]string{ + "keywords": "keyword", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + }, + Blocked: []string{"name", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-all", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-mine", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +list-shared", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "name", "offset", "page", "subject", "text", "title"}, + }, + { + CLIPath: "minutes +mindmap", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +prepare-asr", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes +record-pause", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-resume", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-start", + Blocked: []string{"session-ids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids"}, + }, + { + CLIPath: "minutes +record-stop", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes +record-wrap-up", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +replace-batch", + Aliases: map[string]string{ + "pairs": "pair", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "original-text", "record-id", "replace", "replacement-text", "search", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +search", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "keyword": "query", + "keywords": "query", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "name", "offset", "page", "subject", "text", "time", "title"}, + }, + { + CLIPath: "minutes +share", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "user-ids": "member-uids", + "users": "member-uids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"at-user-ids", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "policy", "record-id", "staff-id", "task-id", "uid", "union-id", "url", "user", "user-id", "userid"}, + }, + { + CLIPath: "minutes +speaker-insights", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-ids", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +speaker-replace", + Aliases: map[string]string{ + "new-speaker": "to", + "old-speaker": "from", + "source-speaker": "from", + "target-speaker": "to", + "task-uuid": "id", + "uid": "target-uid", + "user-id": "target-uid", + "userid": "target-uid", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "open-dingtalk-id", "record-id", "staff-id", "target-uids", "task-id", "task-uuids", "union-id", "url", "user", "uuids"}, + }, + { + CLIPath: "minutes +summary", + Aliases: map[string]string{ + "body": "content", + "task-uuid": "id", + "text": "content", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "record-id", "task-id", "task-uuids", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes +transcript", + Aliases: map[string]string{ + "keywords": "keyword", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-token": "cursor", + "q": "keyword", + "query": "keyword", + "search-word": "keyword", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "offset", "page", "record-id", "subject", "task-id", "task-uuids", "text", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes +unshare", + Aliases: map[string]string{ + "task-uuid": "id", + "task-uuids": "ids", + "user-ids": "member-uids", + "users": "member-uids", + "uuid": "id", + "uuids": "ids", + }, + Blocked: []string{"at-user-ids", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "record-id", "staff-id", "task-id", "uid", "union-id", "url", "user", "user-id", "userid"}, + }, + { + CLIPath: "minutes +update", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes +upload", + Blocked: []string{"file-name", "file-size", "size-bytes", "template-ids"}, + }, + { + CLIPath: "minutes +upload-and-analyze", + Aliases: map[string]string{ + "task-uuid": "resume-id", + "uuid": "resume-id", + }, + Blocked: []string{"file-name", "file-size", "id", "resume-ids", "size-bytes", "template-ids"}, + }, + { + CLIPath: "minutes audio-memo list", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "limit": "max", + "max-results": "max", + "max-time": "end", + "min-time": "start", + "next-cursor": "cursor", + "page-size": "max", + "since": "start", + "start-date": "start", + "start-time": "start", + "time-max": "end", + "time-min": "start", + }, + Blocked: []string{"date", "next-page-token", "next-token", "page-token", "time"}, + }, + { + CLIPath: "minutes get audio", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get batch", + Aliases: map[string]string{ + "task-uuids": "ids", + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes get info", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get keywords", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get summary", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get todos", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes get transcription", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes hot-word add", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes hot-word delete", + Aliases: map[string]string{ + "hot-words": "words", + "hotwords": "words", + }, + Blocked: []string{"hot-word", "keyword", "keywords", "query", "search", "word"}, + }, + { + CLIPath: "minutes list all", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes list mine", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes list shared", + Aliases: map[string]string{ + "begin": "start", + "end-date": "end", + "end-time": "end", + "from": "start", + "from-date": "start", + "max-result": "limit", + "max-results": "limit", + "max-time": "end", + "min-time": "start", + "page-size": "limit", + "per-page": "limit", + "since": "start", + "size": "limit", + "start-date": "start", + "start-time": "start", + "take": "limit", + "time-max": "end", + "time-min": "start", + "top": "limit", + }, + Blocked: []string{"count", "date", "date-range", "page", "time"}, + }, + { + CLIPath: "minutes mind-graph create", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes mind-graph status", + Blocked: []string{"ids", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes permission add", + Aliases: map[string]string{ + "user-ids": "member-uids", + "users": "member-uids", + }, + Blocked: []string{"at-user-ids", "id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "permission", "staff-id", "task-uuid", "uid", "union-id", "url", "user", "user-id", "userid", "uuid"}, + }, + { + CLIPath: "minutes permission apply", + Blocked: []string{"ids", "permission", "task-uuids", "uuids"}, + }, + { + CLIPath: "minutes permission remove", + Aliases: map[string]string{ + "user-ids": "member-uids", + "users": "member-uids", + }, + Blocked: []string{"at-user-ids", "id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "open-dingtalk-id", "staff-id", "task-uuid", "uid", "union-id", "url", "user", "user-id", "userid", "uuid"}, + }, + { + CLIPath: "minutes record pause", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes record resume", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes record start", + Blocked: []string{"session-ids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids"}, + }, + { + CLIPath: "minutes record stop", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "session-ids", "task-id", "task-uuids", "upload-id", "upload-ids", "upload-session-id", "upload-session-ids", "url", "uuids"}, + }, + { + CLIPath: "minutes replace-text", + Aliases: map[string]string{ + "find": "search", + "new-text": "replace", + "original-text": "search", + "replacement-text": "replace", + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"body", "content", "ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "text", "url", "uuids"}, + }, + { + CLIPath: "minutes speaker replace", + Aliases: map[string]string{ + "new-speaker": "to", + "old-speaker": "from", + "source-speaker": "from", + "target-speaker": "to", + "task-uuid": "id", + "uid": "target-uid", + "user-id": "target-uid", + "userid": "target-uid", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "open-dingtalk-id", "record-id", "staff-id", "target-uids", "task-id", "task-uuids", "union-id", "url", "user", "uuids"}, + }, + { + CLIPath: "minutes speaker summary create", + Aliases: map[string]string{ + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes speaker summary get", + Aliases: map[string]string{ + "uuids": "ids", + }, + Blocked: []string{"id", "meeting-id", "meeting-ids", "minutes-id", "minutes-ids", "task-uuid", "url", "uuid"}, + }, + { + CLIPath: "minutes tag query", + Aliases: map[string]string{ + "id": "tag-id", "max-result": "limit", "max-results": "limit", "page-size": "limit", @@ -5582,7 +6615,45 @@ var generatedParamAliases = []ParamAliasEntry{ "take": "limit", "top": "limit", }, - Blocked: []string{"count", "cursor"}, + Blocked: []string{"count", "group-id", "group-name", "page", "tag-ids"}, + }, + { + CLIPath: "minutes update summary", + Aliases: map[string]string{ + "body": "content", + "task-uuid": "id", + "text": "content", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "name", "record-id", "task-id", "task-uuids", "title", "url", "uuids"}, + }, + { + CLIPath: "minutes update title", + Aliases: map[string]string{ + "task-uuid": "id", + "uuid": "id", + }, + Blocked: []string{"ids", "meeting-id", "minutes-id", "record-id", "task-id", "task-uuids", "url", "uuids"}, + }, + { + CLIPath: "minutes upload cancel", + Aliases: map[string]string{ + "upload-id": "session-id", + "upload-session-id": "session-id", + }, + Blocked: []string{"id", "minutes-id", "recording-session-id", "session-ids", "task-id", "task-uuid", "upload-ids", "upload-session-ids"}, + }, + { + CLIPath: "minutes upload complete", + Aliases: map[string]string{ + "upload-id": "session-id", + "upload-session-id": "session-id", + }, + Blocked: []string{"id", "minutes-id", "recording-session-id", "session-ids", "task-id", "task-uuid", "upload-ids", "upload-session-ids"}, + }, + { + CLIPath: "minutes upload create", + Blocked: []string{"file", "file-path", "path", "template-ids"}, }, { CLIPath: "oa +search-forms", @@ -5622,6 +6693,917 @@ var generatedParamAliases = []ParamAliasEntry{ CLIPath: "report outbox list", Blocked: []string{"template-type"}, }, + { + CLIPath: "todo +assign", + Aliases: map[string]string{ + "assignee-name": "to", + "deadline": "due", + "due-time": "due", + "title": "task", + }, + Blocked: []string{"due-date-offset", "executor-id", "executor-ids", "executors", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "user-id", "user-ids"}, + }, + { + CLIPath: "todo +assign-multi", + Aliases: map[string]string{ + "assignee-names": "to", + "title": "task", + }, + Blocked: []string{"executor-id", "executor-ids", "executors", "user-id", "user-ids"}, + }, + { + CLIPath: "todo +comment", + Aliases: map[string]string{ + "body": "content", + "text": "content", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "name", "open-task-id", "parent-id", "task", "task-ids", "title", "todo-ids"}, + }, + { + CLIPath: "todo +complete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "done", "id", "ids", "is-done", "open-task-id", "parent-id", "status", "task", "task-ids", "todo-ids", "todo-status"}, + }, + { + CLIPath: "todo +create", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "task": "title", + }, + Blocked: []string{"due-date-offset", "executor", "executor-id", "name", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "to", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo +due-today", + Aliases: map[string]string{ + "roles": "role-types", + }, + Blocked: []string{"role", "role-id", "role-ids"}, + }, + { + CLIPath: "todo +get", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +get-my-tasks", + Aliases: map[string]string{ + "current-page": "page", + "done": "status", + "is-done": "status", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "priorities": "priority", + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"cursor", "limit", "max-result", "max-results", "page-index", "page-token", "plan-finish-date-end", "plan-finish-date-start", "priority-id", "priority-name", "role", "role-id", "role-ids", "state", "take", "task-status", "top"}, + }, + { + CLIPath: "todo +get-related-tasks", + Aliases: map[string]string{ + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"done", "is-done", "role", "role-id", "role-ids"}, + }, + { + CLIPath: "todo +list-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +list-comment", + Aliases: map[string]string{ + "current-page": "page", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "take": "size", + "todo-id": "task-id", + "top": "size", + }, + Blocked: []string{"attachment-id", "comment-id", "count", "cursor", "id", "ids", "open-task-id", "page-index", "page-token", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +list-sub", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +remind", + Aliases: map[string]string{ + "deadline": "at", + "due": "at", + "due-time": "at", + "title": "task", + }, + Blocked: []string{"remind-at", "reminder-at", "reminder-rules", "reminder-time", "reminder-time-stamp"}, + }, + { + CLIPath: "todo +reminder", + Aliases: map[string]string{ + "remind-at": "at", + "reminder-at": "at", + "reminder-time": "at", + "reminder-time-stamp": "at", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "deadline", "due", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-rules", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo +reopen", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "done", "id", "ids", "is-done", "open-task-id", "parent-id", "status", "task", "task-ids", "todo-ids", "todo-status"}, + }, + { + CLIPath: "todo +search", + Aliases: map[string]string{ + "done": "status", + "is-done": "status", + "keyword": "query", + "keywords": "query", + "q": "query", + "search-word": "query", + "subject": "query", + "task": "query", + "title": "query", + "todo-status": "status", + }, + Blocked: []string{"limit", "max-result", "max-results", "name", "page", "page-size", "per-page", "size", "state", "take", "task-status", "text", "top"}, + }, + { + CLIPath: "todo +todo-done", + Aliases: map[string]string{ + "keyword": "task", + "query": "task", + "title": "task", + }, + Blocked: []string{"task-id", "todo-id"}, + }, + { + CLIPath: "todo +update", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "id", "ids", "open-task-id", "parent-id", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "remind-at", "reminder-at", "reminder-time", "reminder-time-stamp", "task-ids", "todo-ids"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "todo comment add", + Aliases: map[string]string{ + "body": "content", + "text": "content", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "name", "open-task-id", "parent-id", "task", "task-ids", "title", "todo-ids"}, + }, + { + CLIPath: "todo comment delete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo comment list", + Aliases: map[string]string{ + "current-page": "page", + "limit": "size", + "max-result": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "page-size": "size", + "per-page": "size", + "take": "size", + "todo-id": "task-id", + "top": "size", + }, + Blocked: []string{"attachment-id", "comment-id", "count", "cursor", "id", "ids", "open-task-id", "page-index", "page-token", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo tag add", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "tag-ids", "tag-names", "tags", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo tag delete", + Blocked: []string{"tag-ids", "tag-names", "tags"}, + }, + { + CLIPath: "todo tag update", + Blocked: []string{"tag-codes", "tag-ids", "tag-names", "tags"}, + }, + { + CLIPath: "todo task add-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task add-executor", + Aliases: map[string]string{ + "executor-ids": "executors", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "participant-ids", "participants", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task add-participant", + Aliases: map[string]string{ + "participant-ids": "participants", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "executor-ids", "executors", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task add-reminder", + Aliases: map[string]string{ + "at": "reminder-time-stamp", + "reminder-at": "reminder-time-stamp", + "reminder-time": "reminder-time-stamp", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "deadline", "due", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-rules", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task create", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "task": "title", + }, + Blocked: []string{"due-date-offset", "executor", "executor-id", "name", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "to", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task create-sub", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "executor-ids": "executors", + "parent-task-id": "parent-id", + "task-id": "parent-id", + "todo-id": "parent-id", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "executor", "executor-id", "id", "ids", "name", "open-task-id", "participant", "participant-id", "participant-ids", "participants", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "todo task delete", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task done", + Aliases: map[string]string{ + "done": "status", + "is-done": "status", + "todo-id": "task-id", + "todo-status": "status", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "state", "task", "task-ids", "task-status", "todo-ids"}, + }, + { + CLIPath: "todo task get", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task list", + Aliases: map[string]string{ + "current-page": "page", + "done": "status", + "is-done": "status", + "limit": "size", + "max-results": "size", + "page-no": "page", + "page-num": "page", + "page-number": "page", + "priorities": "priority", + "roles": "role-types", + "todo-status": "status", + }, + Blocked: []string{"cursor", "page-index", "page-size", "page-token", "plan-finish-end", "plan-finish-start", "priority-id", "priority-name", "role", "role-id", "role-ids", "state", "task-status"}, + }, + { + CLIPath: "todo task list-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task list-sub", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task remove-attachment", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"comment-id", "id", "ids", "open-task-id", "parent-id", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task remove-executor", + Aliases: map[string]string{ + "executor-ids": "executors", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "participant-ids", "participants", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task remove-participant", + Aliases: map[string]string{ + "participant-ids": "participants", + "todo-id": "task-id", + }, + Blocked: []string{"attachment-id", "comment-id", "executor", "executor-id", "executor-ids", "executors", "id", "ids", "name", "open-task-id", "parent-id", "participant", "participant-id", "task", "task-ids", "to", "todo-ids", "user", "user-id", "user-ids", "users"}, + }, + { + CLIPath: "todo task reset-reminder", + Aliases: map[string]string{ + "todo-id": "task-id", + }, + Blocked: []string{"at", "attachment-id", "base-time", "comment-id", "deadline", "due", "due-date-offset", "due-time", "id", "ids", "open-task-id", "parent-id", "reminder-time-stamp", "task", "task-ids", "todo-ids"}, + }, + { + CLIPath: "todo task update", + Aliases: map[string]string{ + "deadline": "due", + "due-time": "due", + "is-done": "done", + "status": "done", + "todo-id": "task-id", + "todo-status": "done", + }, + Blocked: []string{"attachment-id", "comment-id", "due-date-offset", "id", "ids", "open-task-id", "parent-id", "plan-finish-date-end", "plan-finish-date-start", "plan-finish-end", "plan-finish-start", "reminder-at", "reminder-time", "reminder-time-stamp", "state", "task-ids", "task-status", "todo-ids"}, + Ambiguous: []string{"task"}, + }, + { + CLIPath: "wiki +delete-space", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki +feed-list", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +member-add", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-list", + Aliases: map[string]string{ + "filter-roles": "filter-role", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "role-filter": "filter-role", + "roles": "filter-role", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-remove", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +member-update", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "uid": "user", + "user-id": "user", + "user-ids": "users", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"at-user-ids", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id", "userid"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +move", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +move-to-drive", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + }, + Blocked: []string{"dentry-id", "knowledge-base-id", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +node-copy", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-id": "node", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki +node-create", + Aliases: map[string]string{ + "directory-id": "folder", + "display-name": "name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-name": "name", + "node-type": "type", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "query", "space", "space-id", "space-type", "storage-space-id", "wiki-space-type"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +node-delete", + Aliases: map[string]string{ + "document-id": "node", + "knowledge-base-id": "workspace", + "node-id": "node", + "target-node-id": "node", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki +node-get", + Aliases: map[string]string{ + "doc-id": "node", + "document-id": "node", + "document-url": "node", + "id": "node", + "node-id": "node", + "url": "node", + "wiki-node-id": "node", + }, + Blocked: []string{"dentry-id", "folder", "folder-id", "knowledge-base-id", "space-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +node-list", + Aliases: map[string]string{ + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "parent-folder-id": "folder", + "parent-node-id": "folder", + "per-page": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id", "max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +node-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "node-name": "query", + "page-size": "limit", + "page-token": "cursor", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "name", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id", "subject", "text", "title"}, + }, + { + CLIPath: "wiki +resolve-space", + Aliases: map[string]string{ + "keyword": "name", + "keywords": "name", + "knowledge-base-name": "name", + "q": "name", + "query": "name", + "search-word": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"id", "knowledge-base-id", "space-id", "subject", "text", "title", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +space-create", + Aliases: map[string]string{ + "description": "desc", + "display-name": "name", + "knowledge-base-name": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"config", "field-description", "node-name", "query", "title", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +space-get", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + "workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki +space-list", + Aliases: map[string]string{ + "next-cursor": "cursor", + "next-page-token": "cursor", + "next-token": "cursor", + "page-size": "limit", + "per-page": "limit", + "space-type": "type", + "wiki-space-type": "type", + }, + Blocked: []string{"id", "knowledge-base-id", "node-type", "offset", "page", "space", "space-id", "storage-space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"max-result", "max-results", "size", "take", "top"}, + }, + { + CLIPath: "wiki +space-search", + Aliases: map[string]string{ + "keyword": "query", + "keywords": "query", + "knowledge-base-name": "query", + "max-result": "limit", + "max-results": "limit", + "page-size": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "space-name": "query", + "take": "limit", + "top": "limit", + "wiki-name": "query", + "workspace-name": "query", + }, + Blocked: []string{"count", "cursor", "id", "knowledge-base-id", "name", "page", "space-id", "space-type", "subject", "text", "title", "type", "wiki-space-type", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki +wiki-new-doc", + Aliases: map[string]string{ + "doc-title": "title", + "document-title": "title", + "knowledge-base-name": "space", + "space-name": "space", + "wiki-name": "space", + "workspace-name": "space", + }, + Blocked: []string{"id", "knowledge-base-id", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + Ambiguous: []string{"name"}, + }, + { + CLIPath: "wiki feed list", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "max-items", "node", "node-id", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki member add", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member list", + Aliases: map[string]string{ + "filter-roles": "filter-role", + "knowledge-base-id": "workspace", + "max-result": "limit", + "per-page": "limit", + "role-filter": "filter-role", + "roles": "filter-role", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "cursor", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "page", "page-all", "role", "space", "space-id", "storage-space-id", "user-id", "user-ids", "users"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member remove", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki member update", + Aliases: map[string]string{ + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "name", "node", "node-id", "open-dingtalk-id", "open-dingtalk-ids", "space", "space-id", "staff-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node copy", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "knowledge-base-id": "workspace", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki node create", + Aliases: map[string]string{ + "directory-id": "folder", + "display-name": "name", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "node-name": "name", + "node-type": "type", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "node", "node-id", "query", "space", "space-id", "space-type", "storage-space-id", "wiki-space-type"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node delete", + Aliases: map[string]string{ + "document-id": "node", + "knowledge-base-id": "workspace", + "target-node-id": "node", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node list", + Aliases: map[string]string{ + "directory-id": "folder", + "folder-id": "folder", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "max-items", "node", "node-id", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"id"}, + }, + { + CLIPath: "wiki node move", + Aliases: map[string]string{ + "destination-folder-id": "folder", + "document-id": "node", + "knowledge-base-id": "workspace", + "source-document-id": "node", + "source-node-id": "node", + "target-folder-id": "folder", + "wiki-node-id": "node", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder-id", "source-folder-id", "space", "space-id", "storage-space-id"}, + Ambiguous: []string{"destination-id", "id", "target-id"}, + }, + { + CLIPath: "wiki node search", + Aliases: map[string]string{ + "keywords": "query", + "knowledge-base-id": "workspace", + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "node-name": "query", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "take": "limit", + "top": "limit", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"count", "dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "id", "name", "node", "node-id", "offset", "page", "space", "space-id", "storage-space-id", "subject", "text", "title"}, + }, + { + CLIPath: "wiki space create", + Aliases: map[string]string{ + "display-name": "name", + "knowledge-base-name": "name", + "space-name": "name", + "wiki-name": "name", + "workspace-name": "name", + }, + Blocked: []string{"config", "field-description", "node-name", "query", "title", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki space delete", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki space get", + Aliases: map[string]string{ + "id": "workspace", + "knowledge-base-id": "workspace", + "wiki-workspace-id": "workspace", + }, + Blocked: []string{"dentry-id", "dingdrive-space-id", "drive-space-id", "folder", "folder-id", "node", "node-id", "space", "space-id", "storage-space-id"}, + }, + { + CLIPath: "wiki space list", + Aliases: map[string]string{ + "max-result": "limit", + "max-results": "limit", + "next-cursor": "cursor", + "next-page-token": "cursor", + "per-page": "limit", + "size": "limit", + "space-type": "type", + "take": "limit", + "top": "limit", + }, + Blocked: []string{"count", "id", "knowledge-base-id", "max-items", "node-type", "offset", "page", "page-all", "page-delay", "page-limit", "space", "space-id", "wiki-workspace-id", "workspace", "workspace-id"}, + }, + { + CLIPath: "wiki space search", + Aliases: map[string]string{ + "keywords": "query", + "knowledge-base-name": "query", + "max-result": "limit", + "max-results": "limit", + "per-page": "limit", + "q": "query", + "search-word": "query", + "size": "limit", + "space-name": "query", + "space-type": "type", + "take": "limit", + "top": "limit", + "wiki-name": "query", + "wiki-space-type": "type", + "workspace-name": "query", + }, + Blocked: []string{"count", "cursor", "id", "knowledge-base-id", "name", "page", "space-id", "subject", "text", "title", "wiki-workspace-id", "workspace", "workspace-id"}, + }, } func loadGeneratedParamAliases() []ParamAliasEntry { diff --git a/internal/cli/param_concepts.json b/internal/cli/param_concepts.json index 90570a9e..0563dead 100644 --- a/internal/cli/param_concepts.json +++ b/internal/cli/param_concepts.json @@ -10,16 +10,16 @@ }, "concepts": { - "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms"],"risk":"green"}, - "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","oa +list-executed"],"risk":"green"}, - "page_number": {"denotes": "one-based page number", "canonical_hint": "page", "members": ["page", "page-no", "current-page", "page-num"], "excludes": ["cursor", "page-index", "page-size", "page-token"], "commands": ["devdoc article search"], "risk": "green"}, - "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list"],"risk":"green"}, - "content_text": {"denotes": "text body content", "canonical_hint": "text", "members": ["text", "content", "body"], "excludes": ["title", "name"], "commands": ["doc +checkpoint-update", "doc +comment-create", "doc +comment-reply", "doc +comment-update", "doc +create", "doc +doc-append", "doc block insert", "doc block update", "doc comment create", "doc comment create-inline", "doc comment reply", "doc comment update", "doc create"], "risk": "green"}, - "time_start": {"denotes": "start time point with unchanged value format and unit", "canonical_hint": "start", "members": ["start", "start-time", "start-date", "from", "from-date", "begin", "since", "time-min", "min-time"], "excludes": ["date", "time", "end"], "commands": ["calendar event list", "chat message list-all", "report list"], "risk": "yellow"}, - "time_end": {"denotes": "end time point with unchanged value format and unit", "canonical_hint": "end", "members": ["end", "end-time", "end-date", "time-max", "max-time"], "excludes": ["date", "time", "start"], "commands": ["calendar event list"], "risk": "yellow"}, + "search_query": {"denotes":"search keyword string","canonical_hint":"query","members":["query","keyword","keywords","q","search-word"],"excludes":["name","subject","text","title"],"commands":["aitable +base-search","aitable +find-record","aitable +record-bulk-patch","aitable +record-query","aitable +template-search","aitable base search","aitable field search-options","aitable record list","aitable record query","aitable search","aitable template search","contact +dept-members","contact +resolve-dept","contact +search-user","doc +create-from-template","doc +find-doc","doc +search","doc +template-search","doc template search","drive +find-file","drive +search","drive +search-docs","drive search","mail +find-mail-user","mail user search","oa +search-forms","oa approval search-forms","calendar +book-search","calendar +search-event","minutes +latest","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","todo +search","wiki +node-search","wiki +resolve-space","wiki +space-search","wiki node search","wiki space search"],"risk":"green"}, + "pagination_size": {"denotes":"returned item count upper bound","canonical_hint":"limit","members":["limit","size","page-size","max-results","max-result","take","top","per-page"],"excludes":["count","page","cursor"],"commands":["aitable +base-list","aitable +record-history-list","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable +workflow-list","aitable base list","aitable field search-options","aitable list","aitable record history-list","aitable record list","aitable record query","aitable record query-empty","aitable template search","aitable workflow history","aitable workflow list","calendar event list","chat message list","devdoc article search","doc +comment-list","doc +find-doc","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +search-docs","drive +star-list","drive +version-history","drive list","drive list-spaces","drive permission list","drive recent","drive recycle list","drive search","drive star list","mail thread list","calendar +agenda","calendar +room-groups","calendar +room-find","calendar +search-event","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes list all","minutes list mine","minutes list shared","minutes tag query","todo +list-comment","todo comment list","wiki +member-list","wiki +node-search","wiki +space-search","wiki feed list","wiki member list","wiki node list","wiki node search","wiki space list","wiki space search"],"risk":"green"}, + "page_number": {"denotes":"one-based page number","canonical_hint":"page","members":["page","page-no","current-page","page-num"],"excludes":["cursor","page-index","page-size","page-token"],"commands":["devdoc article search","todo +get-my-tasks","todo +list-comment","todo comment list","todo task list"],"risk":"green"}, + "page_cursor": {"denotes":"pagination cursor/token","canonical_hint":"cursor","members":["cursor","next-cursor","page-token","next-token","next-page-token"],"excludes":["page","offset"],"commands":["aitable +base-list","aitable +base-search","aitable +record-query","aitable +record-query-empty","aitable +template-search","aitable base list","aitable base search","aitable list","aitable record list","aitable record query","aitable record query-empty","aitable search","aitable template search","calendar event list","doc +comment-list","doc +list","doc +search","doc +template-list","doc +template-search","doc +version-list","doc comment list","doc template list","doc template search","doc version list","drive +list","drive +recent","drive +recycle-list","drive +search","drive +star-list","drive +version-history","drive list","drive list-spaces","drive recent","drive recycle list","drive search","drive star list","calendar +agenda","calendar +search-event","minutes +detail","minutes +list-all","minutes +list-mine","minutes +list-shared","minutes +search","minutes +transcript","wiki +feed-list","wiki +node-search","wiki feed list","wiki node list","wiki node search","wiki space list"],"risk":"green"}, + "content_text": {"denotes":"text body content","canonical_hint":"text","members":["text","content","body"],"excludes":["title","name"],"commands":["doc +checkpoint-update","doc +comment-create","doc +comment-reply","doc +comment-update","doc +create","doc +doc-append","doc block insert","doc block update","doc comment create","doc comment create-inline","doc comment reply","doc comment update","doc create","minutes +summary","minutes update summary","todo +comment","todo comment add"],"risk":"green"}, + "time_start": {"denotes":"start time point with unchanged value format and unit","canonical_hint":"start","members":["start","start-time","start-date","from","from-date","begin","since","time-min","min-time"],"excludes":["date","time","end"],"commands":["calendar event list","chat message list-all","report list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"}, + "time_end": {"denotes":"end time point with unchanged value format and unit","canonical_hint":"end","members":["end","end-time","end-date","time-max","max-time"],"excludes":["date","time","start"],"commands":["calendar event list","calendar +agenda","calendar +free","calendar +freebusy","calendar +my-free","calendar +suggest-time","calendar +room-find","calendar +search-event","calendar +suggestion","minutes +search","minutes audio-memo list","minutes list all","minutes list mine","minutes list shared"],"risk":"yellow"}, "base_id": {"denotes":"multi-dimensional table Base id","canonical_hint":"base-id","members":["base","base-id","base-token"],"excludes":[],"commands":["aitable +advperm-disable","aitable +advperm-enable","aitable +attachment-put","aitable +attachment-remove","aitable +attachment-upload","aitable +base-copy","aitable +base-delete","aitable +base-get","aitable +base-get-primary-doc-id","aitable +base-schema-snapshot","aitable +base-update","aitable +chart-delete","aitable +chart-get","aitable +chart-share-get","aitable +chart-share-update","aitable +chart-update","aitable +dashboard-arrange","aitable +dashboard-delete","aitable +dashboard-get","aitable +dashboard-share-get","aitable +dashboard-share-update","aitable +dashboard-update","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-upload","aitable +list-tables","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +resolve-table","aitable +role-create","aitable +role-delete","aitable +role-get","aitable +role-list","aitable +role-update","aitable +section-create","aitable +section-delete","aitable +section-list-empty","aitable +section-list-nodes","aitable +section-move-node","aitable +section-rename","aitable +section-reorder","aitable +table-delete","aitable +table-get","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable +workflow-list","aitable advperm disable","aitable advperm enable","aitable advperm role-create","aitable advperm role-delete","aitable advperm role-get","aitable advperm role-list","aitable advperm role-update","aitable attachment upload","aitable base copy","aitable base delete","aitable base get","aitable base get-primary-doc-id","aitable base update","aitable chart create","aitable chart delete","aitable chart get","aitable chart share get","aitable chart share update","aitable chart update","aitable dashboard arrange","aitable dashboard create","aitable dashboard delete","aitable dashboard get","aitable dashboard share get","aitable dashboard share update","aitable dashboard update","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import upload","aitable info","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable section create","aitable section delete","aitable section list-empty","aitable section list-nodes","aitable section move-node","aitable section rename","aitable section reorder","aitable table create","aitable table delete","aitable table get","aitable table list","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow create","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow list","aitable workflow run","aitable workflow update"],"risk":"green"}, "table_id": {"denotes":"single AI Table data-table Table ID","canonical_hint":"table-id","members":["table","table-id"],"excludes":["table-ids","source-table-id","target-table-id","name"],"commands":["aitable +attachment-put","aitable +attachment-remove","aitable +base-get-primary-doc-id","aitable +export-data","aitable +field-delete","aitable +field-get","aitable +field-update","aitable +find-record","aitable +form-delete","aitable +form-field-hide","aitable +form-field-list","aitable +form-field-update","aitable +form-list","aitable +form-share-get","aitable +form-share-update","aitable +form-update","aitable +import-data","aitable +record-bulk-patch","aitable +record-delete","aitable +record-history-list","aitable +record-primary-doc-create","aitable +record-primary-doc-get","aitable +record-query","aitable +record-query-empty","aitable +record-share-links","aitable +record-share-url","aitable +record-update","aitable +record-upsert","aitable +record-upsert-by-key","aitable +table-delete","aitable +table-update","aitable +view-delete","aitable +view-duplicate","aitable +view-get","aitable +view-get-frozen-cols","aitable +view-get-lock","aitable +view-get-row-height","aitable +view-lock","aitable +view-preset-apply","aitable +view-set-fill-color-rule","aitable +view-set-frozen-cols","aitable +view-set-row-height","aitable +view-update","aitable base get-primary-doc-id","aitable export data","aitable field create","aitable field delete","aitable field get","aitable field list","aitable field search-options","aitable field update","aitable form create","aitable form delete","aitable form field hide","aitable form field list","aitable form field update","aitable form get","aitable form list","aitable form questions create","aitable form questions delete","aitable form share get","aitable form share update","aitable form update","aitable import data","aitable record batch-update","aitable record create","aitable record delete","aitable record get","aitable record history-list","aitable record primary-doc-create","aitable record primary-doc-get","aitable record query","aitable record query-empty","aitable record share-url","aitable record update","aitable record upsert","aitable table delete","aitable table update","aitable view create","aitable view delete","aitable view duplicate","aitable view get aggregate","aitable view get card","aitable view get field-widths","aitable view get fill-color-rule","aitable view get filter","aitable view get frozen-cols","aitable view get group","aitable view get lock","aitable view get row-height","aitable view get sort","aitable view get timebar","aitable view get visible-fields","aitable view list","aitable view lock","aitable view update aggregate","aitable view update card","aitable view update field-widths","aitable view update fill-color-rule","aitable view update filter","aitable view update frozen-cols","aitable view update group","aitable view update name","aitable view update row-height","aitable view update sort","aitable view update timebar","aitable view update visible-fields","aitable workflow run"],"risk":"green"}, - "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update"],"risk":"green"}, + "plain_description": {"denotes":"plain-text description or remark with unchanged value","canonical_hint":"description","members":["description","desc"],"excludes":["field-description","config"],"commands":["aitable +base-update","aitable +form-update","aitable +table-update","aitable base update","aitable form create","aitable form update","aitable table update","calendar +create","calendar +update","wiki +space-create","wiki space create"],"risk":"green"}, "workflow_id": {"denotes":"single AI Table workflow identifier; service flowId and CLI workflow-id carry the same value","canonical_hint":"workflow-id","members":["workflow-id","flow-id"],"excludes":["id","task-id"],"commands":["aitable +workflow-deploy","aitable +workflow-disable","aitable +workflow-enable","aitable +workflow-get","aitable workflow disable","aitable workflow enable","aitable workflow get","aitable workflow history","aitable workflow run","aitable workflow update"],"risk":"green"}, "dept_id": {"denotes": "single department id", "canonical_hint": "dept", "members": ["dept", "dept-id", "department", "department-id", "parent", "parent-id"], "excludes": ["depts", "dept-ids", "department-ids", "name", "query"], "commands": ["contact +list-sub-depts", "contact dept list-children"], "risk": "yellow"}, "dept_ids": {"denotes": "department id list", "canonical_hint": "dept-ids", "members": ["depts", "dept-ids", "department-ids"], "excludes": ["dept", "dept-id", "department-id", "name", "query"], "commands": ["contact +list-dept-members"], "risk": "yellow"}, @@ -31,7 +31,7 @@ "open_message_ids": {"denotes": "DingTalk openMessageId list with unchanged element values", "canonical_hint": "msg-ids", "members": ["msg-ids", "message-ids", "open-message-ids"], "excludes": ["msg-id", "message-id", "open-message-id", "ref-msg-id", "src-msg-id"], "commands": ["chat +flag-cancel", "chat +flag-create", "chat +messages-combine-forward", "chat +messages-mget", "chat message combine-forward", "chat message list-by-ids", "chat message list-emotion-replies"], "risk": "yellow"}, "referenced_open_message_id": {"denotes": "referenced DingTalk openMessageId in a reply", "canonical_hint": "ref-msg-id", "members": ["ref-msg-id", "ref-message-id"], "excludes": ["msg-id", "message-id", "open-message-id", "msg-ids", "src-msg-id"], "commands": ["chat +messages-reply", "chat message reply"], "risk": "yellow"}, "user_id": {"denotes": "single user id", "canonical_hint": "user-id", "members": ["user", "user-id", "userid", "uid", "staff-id"], "excludes": ["at-user-ids", "to-user", "users", "user-ids", "name"], "commands": ["chat +chat-role-query-user", "chat +chat-role-set-user", "chat +messages-list-direct", "chat chmod", "chat conversation-info", "chat group transfer-owner", "chat group-role query-user", "chat group-role remove-user", "chat group-role set-user", "chat message list", "chat message send", "contact user profile get"], "risk": "yellow"}, - "user_ids": {"denotes": "user id list", "canonical_hint": "user-ids", "members": ["users", "user-ids"], "excludes": ["user", "user-id", "userid", "uid", "staff-id", "at-user-ids"], "commands": ["attendance +check-result", "attendance check result", "chat +messages-batch-send-by-bot", "chat group members remove", "chat group set-admin", "chat group-mute-member", "chat message read-status", "chat message search-advanced", "chat message send-by-bot"], "risk": "yellow"}, + "user_ids": {"denotes":"user id list","canonical_hint":"user-ids","members":["users","user-ids"],"excludes":["user","user-id","userid","uid","staff-id","at-user-ids"],"commands":["attendance +check-result","attendance check result","chat +messages-batch-send-by-bot","chat group members remove","chat group set-admin","chat group-mute-member","chat message read-status","chat message search-advanced","chat message send-by-bot","calendar +freebusy","calendar +suggestion","minutes +share","minutes +unshare","minutes permission add","minutes permission remove","wiki +member-add","wiki +member-remove","wiki +member-update"],"risk":"yellow"}, "open_dingtalk_ids": {"denotes": "DingTalk openDingTalkId list with unchanged element values", "canonical_hint": "open-dingtalk-ids", "members": ["open-dingtalk-ids"], "excludes": ["user", "user-id", "user-ids", "staff-id", "users"], "commands": ["chat +chat-members-get", "chat category create-smart", "chat group members list-by-ids", "chat message send-by-bot"], "risk": "yellow"}, "ding_id": {"denotes": "DING id", "canonical_hint": "ding-id", "members": ["ding-id", "open-ding-id"], "excludes": ["id"], "commands": ["ding message receiver-status"], "risk": "yellow"}, "folder_id": {"denotes": "drive folder id", "canonical_hint": "folder", "members": ["folder", "folder-id"], "excludes": ["space-id"], "commands": ["drive +copy", "drive +create-folder", "drive +create-shortcut", "drive +list", "drive +move", "drive +upload", "drive commit", "drive copy", "drive list", "drive mkdir", "drive move", "drive shortcut", "drive upload", "drive upload-info", "mail folder update"], "risk": "green"}, @@ -49,12 +49,29 @@ "drive_modified_time_end": {"denotes": "Drive search modified-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "modified-to", "members": ["modified-to", "modified-before", "modify-time-to", "modified-time-end"], "excludes": ["modified-from", "created-from", "created-to", "end", "to"], "commands": ["drive +search", "drive search"], "risk": "yellow"}, "drive_file_size_bytes": {"denotes": "file size in bytes passed unchanged", "canonical_hint": "file-size", "members": ["file-size", "file-size-bytes", "size-bytes", "content-length"], "excludes": ["part-size", "page-size", "limit", "size"], "commands": ["drive commit", "drive upload-info"], "risk": "yellow"}, "drive_sort_direction": {"denotes": "Drive result sort direction with unchanged asc/desc enum", "canonical_hint": "order", "members": ["order", "sort", "sort-direction", "order-direction"], "excludes": ["order-by", "sort-by", "order-field"], "commands": ["drive +list", "drive list", "drive star list"], "risk": "green"}, - "workspace_id": {"denotes": "single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID", "canonical_hint": "workspace", "members": ["workspace", "workspace-id", "knowledge-base-id", "wiki-workspace-id"], "excludes": ["space", "space-id", "drive-space-id", "storage-space-id", "dingdrive-space-id", "folder", "folder-id", "node", "node-id", "dentry-id"], "commands": ["doc +access-change", "doc +access-grant", "doc +access-revoke", "doc +copy", "doc +create", "doc +create-from-template", "doc +grant-and-share", "doc +import", "doc +list", "doc +move", "doc create", "doc file create", "doc import", "doc template apply", "drive +copy", "drive +create-shortcut", "drive +move", "drive copy", "drive list", "drive move", "drive permission add", "drive permission list", "drive permission remove", "drive permission transfer-owner", "drive permission update", "drive shortcut", "drive upload"], "risk": "yellow"}, + "workspace_id": {"denotes":"single knowledge-base or document-space workspace ID/URL passed unchanged; never a numeric DingDrive storage space ID","canonical_hint":"workspace","members":["workspace","workspace-id","knowledge-base-id","wiki-workspace-id"],"excludes":["space","space-id","drive-space-id","storage-space-id","dingdrive-space-id","folder","folder-id","node","node-id","dentry-id"],"commands":["doc +access-change","doc +access-grant","doc +access-revoke","doc +copy","doc +create","doc +create-from-template","doc +grant-and-share","doc +import","doc +list","doc +move","doc create","doc file create","doc import","doc template apply","drive +copy","drive +create-shortcut","drive +move","drive copy","drive list","drive move","drive permission add","drive permission list","drive permission remove","drive permission transfer-owner","drive permission update","drive shortcut","drive upload","wiki +delete-space","wiki +feed-list","wiki +member-add","wiki +member-list","wiki +member-remove","wiki +member-update","wiki +move","wiki +node-copy","wiki +node-create","wiki +node-delete","wiki +node-list","wiki +node-search","wiki +space-get","wiki feed list","wiki member add","wiki member list","wiki member remove","wiki member update","wiki node copy","wiki node create","wiki node delete","wiki node list","wiki node move","wiki node search","wiki space delete","wiki space get"],"risk":"yellow"}, "created_time_start": {"denotes": "Doc/Drive search created-time lower bound in unchanged millisecond timestamp unit", "canonical_hint": "created-from", "members": ["created-from", "created-after", "create-time-from", "created-time-start", "create-time-start"], "excludes": ["created-to", "modified-from", "modified-to", "start", "from"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "created_time_end": {"denotes": "Doc/Drive search created-time upper bound in unchanged millisecond timestamp unit", "canonical_hint": "created-to", "members": ["created-to", "created-before", "create-time-to", "created-time-end", "create-time-end"], "excludes": ["created-from", "modified-from", "modified-to", "end", "to"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, "document_permission_role": {"denotes": "direct document-space permission role on grant/apply/update, passed unchanged", "canonical_hint": "role", "members": ["role", "permission-role", "access-role", "member-role"], "excludes": ["filter-role", "reserve-role", "permission", "public-permission"], "commands": ["doc +access-change", "doc +access-grant", "doc +grant-and-share", "drive permission add", "drive permission apply", "drive permission update"], "risk": "yellow"}, "creator_user_ids": {"denotes": "creator userId list used to filter Doc/Drive search results, passed unchanged", "canonical_hint": "creator-uids", "members": ["creator-uids", "creator-user-ids", "creator-ids", "created-by-user-ids"], "excludes": ["user", "user-id", "user-ids", "users", "owner-id", "modifier-uids"], "commands": ["doc +search", "drive +search", "drive search"], "risk": "yellow"}, - "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"} + "local_output_path": {"denotes": "local destination file or directory path for a download/export result, passed unchanged", "canonical_hint": "output", "members": ["output", "output-path", "destination-path", "save-path"], "excludes": ["file", "file-path", "folder", "folder-id", "content-file"], "commands": ["doc +export", "doc +export-get", "doc +media-download", "doc +resource-download", "doc read", "drive +download", "drive +version-download", "drive download", "drive download-version"], "risk": "green"}, + "calendar_event_id": {"denotes":"single calendar eventId in the target-event role","canonical_hint":"event","members":["event","event-id","calendar-event-id"],"excludes":["id","calendar-id","calendar-book-id","acl-id","room-id"],"commands":["calendar +attendee-list","calendar +cancel-event","calendar +invite","calendar +reschedule","calendar +get","calendar +rsvp","calendar +update"],"risk":"yellow"}, + "calendar_book_id": {"denotes":"single calendar container calendarId, not an eventId","canonical_hint":"calendar-id","members":["calendar-id","calendar","calendar-book-id"],"excludes":["id","event","event-id","acl-id","room-id"],"commands":["calendar +agenda","calendar +attendee-list","calendar +create","calendar +get","calendar +rsvp","calendar +search-event","calendar +update"],"risk":"yellow"}, + "calendar_event_title": {"denotes":"calendar event title text","canonical_hint":"title","members":["title","summary","subject"],"excludes":["name","room-name","calendar-name"],"commands":["calendar +book","calendar +create","calendar +update"],"risk":"green"}, + "calendar_person_name_list": {"denotes":"comma-separated DingTalk person names resolved by the shortcut","canonical_hint":"with","members":["with","names","attendee-names","participant-names"],"excludes":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"commands":["calendar +book","calendar +invite","calendar +suggest-time"],"risk":"yellow"}, + "calendar_room_ids": {"denotes":"calendar meeting roomId list with unchanged element values; one roomId is a one-element list","canonical_hint":"rooms","members":["rooms","room-id","room-ids"],"excludes":["room","room-name","group-id","location"],"commands":["calendar event create","calendar room add","calendar room delete","calendar busy search","calendar +create","calendar +freebusy"],"risk":"yellow"}, + "calendar_duration_minutes": {"denotes":"meeting duration in minutes with unchanged numeric text","canonical_hint":"duration","members":["duration","duration-minutes","meeting-duration-minutes"],"excludes":["remind-minutes","start","end","from","to"],"commands":["calendar event suggest","calendar +suggest-time","calendar +suggestion"],"risk":"green"}, + "calendar_timezone": {"denotes":"IANA timezone string with unchanged value","canonical_hint":"timezone","members":["timezone","time-zone","tz"],"excludes":["offset","utc-offset","locale"],"commands":["calendar event create","calendar event suggest","calendar event update","calendar +create","calendar +suggestion","calendar +update"],"risk":"green"}, + "minutes_task_uuid": {"denotes":"single DingTalk Minutes taskUuid with unchanged value","canonical_hint":"task-uuid","members":["task-uuid","uuid"],"excludes":["ids","task-uuids","uuids","url","minutes-id","meeting-id","record-id","task-id"],"commands":["minutes +action-items","minutes +apply-permission","minutes +detail","minutes +download","minutes +export-pack","minutes +mindmap","minutes +record-pause","minutes +record-resume","minutes +record-stop","minutes +record-wrap-up","minutes +replace-batch","minutes +share","minutes +speaker-insights","minutes +speaker-replace","minutes +summary","minutes +transcript","minutes +unshare","minutes +update","minutes record pause","minutes record resume","minutes record stop","minutes replace-text","minutes speaker replace","minutes update summary","minutes update title"],"risk":"yellow"}, + "minutes_task_uuids": {"denotes":"Minutes taskUuid list with unchanged element values and unchanged list encoding","canonical_hint":"ids","members":["ids","task-uuids","uuids"],"excludes":["id","task-uuid","uuid","url","minutes-id","minutes-ids","meeting-id","meeting-ids"],"commands":["minutes +detail","minutes +download","minutes +share","minutes +unshare","minutes get batch","minutes permission add","minutes permission remove","minutes speaker summary create","minutes speaker summary get"],"risk":"yellow"}, + "minutes_hot_words": {"denotes":"personal hot-word list with unchanged values and unchanged list encoding","canonical_hint":"words","members":["words","hot-words","hotwords"],"excludes":["word","hot-word","query","keyword","keywords","search"],"commands":["minutes +prepare-asr","minutes hot-word add","minutes hot-word delete"],"risk":"yellow"}, + "minutes_upload_session_id": {"denotes":"single Minutes file-upload sessionId with unchanged value","canonical_hint":"session-id","members":["session-id","upload-session-id","upload-id"],"excludes":["id","task-id","task-uuid","minutes-id","recording-session-id"],"commands":["minutes upload cancel","minutes upload complete"],"risk":"yellow"}, + "todo_task_id": {"denotes":"single DingTalk todo taskId with unchanged value","canonical_hint":"task-id","members":["task-id","todo-id"],"excludes":["id","ids","task","task-ids","todo-ids","parent-id","comment-id","attachment-id","open-task-id"],"commands":["todo +comment","todo +complete","todo +get","todo +list-attachment","todo +list-comment","todo +list-sub","todo +reminder","todo +reopen","todo +update","todo comment add","todo comment delete","todo comment list","todo tag add","todo task add-attachment","todo task add-executor","todo task add-participant","todo task add-reminder","todo task create-sub","todo task delete","todo task done","todo task get","todo task list-attachment","todo task list-sub","todo task remove-attachment","todo task remove-executor","todo task remove-participant","todo task reset-reminder","todo task update"],"risk":"yellow"}, + "todo_due_time": {"denotes":"todo deadline ISO-8601 value, not an independent reminder time","canonical_hint":"due","members":["due","due-time","deadline"],"excludes":["remind-at","reminder-at","reminder-time","reminder-time-stamp","due-date-offset","plan-finish-start","plan-finish-end","plan-finish-date-start","plan-finish-date-end"],"commands":["todo +assign","todo +create","todo +update","todo task create","todo task create-sub","todo task update"],"risk":"yellow"}, + "todo_executor_ids": {"denotes":"todo executor userId list with unchanged comma-separated value","canonical_hint":"executors","members":["executors","executor-ids"],"excludes":["executor","executor-id","participant","participant-id","participants","participant-ids","to","user","user-id","user-ids","users","name"],"commands":["todo +create","todo task add-executor","todo task create","todo task create-sub","todo task remove-executor"],"risk":"yellow"}, + "todo_participant_ids": {"denotes":"todo participant userId list with unchanged comma-separated value","canonical_hint":"participants","members":["participants","participant-ids"],"excludes":["participant","participant-id","executor","executor-id","executors","executor-ids","to","user","user-id","user-ids","users","name"],"commands":["todo task add-participant","todo task remove-participant"],"risk":"yellow"}, + "todo_completion_state": {"denotes":"todo completion boolean string true/false with unchanged value","canonical_hint":"status","members":["status","done","is-done","todo-status"],"excludes":["state","task-status"],"commands":["todo +get-my-tasks","todo +search","todo task done","todo task list","todo task update"],"risk":"yellow"}, + "todo_role_types": {"denotes":"todo creator/executor/participant role filter list with unchanged comma-separated value","canonical_hint":"role-types","members":["role-types","roles"],"excludes":["role","role-id","role-ids"],"commands":["todo +due-today","todo +get-my-tasks","todo +get-related-tasks","todo task list"],"risk":"yellow"} }, "command_overrides": { @@ -82,6 +99,40 @@ "mail folder update": {"bind": {"id": "folder_id"}, "note": "this command's --id is the folder id; --folder-id reduces to --id"}, "mail message search": {"scoped_aliases": {"subject": "query"}, "scope_strict": true, "note": "never globalize: mail template create has a real and different --subject"}, "calendar event list": {"scoped_aliases": {"date": "start"}, "note": "reviewed against ParseISOTimeToMillis and final list_calendar_events payload; --date is normalized centrally while the command's existing hidden compatibility flags remain native fallbacks"}, + "calendar +agenda": {"block":["id"],"note":"calendarId aliases stay role-specific; bare --id could be an eventId and must not be silently treated as a calendarId. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar +attendee-list": {"ambiguous":["id"],"note":"This command carries both an eventId and an optional calendarId; bare --id cannot safely choose the role."}, + "calendar +book": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id","rooms","room-id","room-ids","date","time","start-date","end-date","time-min","time-max"],"note":"--with carries names resolved by the shortcut; identifier spellings and room booking inputs cannot be supplied by name-only normalization. Only explicit event-time spellings are mapped; range-bound or date-only spellings remain blocked.","scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"scope_strict":true}, + "calendar +book-search": {"scoped_aliases":{"search":"query","name":"query"},"scope_strict":true,"note":"On this exact calendar-book search, --search, --name, and --query carry the same calendar-name keyword."}, + "calendar +cancel-event": {"scoped_aliases":{"id":"event"},"block":["calendar-id","calendar-book-id","acl-id","room-id"],"scope_strict":true,"note":"The only identifier role is the eventId to cancel; other calendar resource IDs are rejected."}, + "calendar +conflicts": {"scoped_aliases":{"day-offset":"in-days","days-from-today":"in-days"},"block":["days","duration","start","end","from","to"],"scope_strict":true,"note":"--in-days is an integer offset from today. Exact day-offset spellings preserve the value; generic duration and absolute/range time names are different roles."}, + "calendar +free": {"block":["user","user-id","user-ids","users","with"],"note":"--who carries one person name for runtime resolution, so exact name/person spellings are command-scoped while stable user identifiers stay blocked. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end","name":"who","person":"who","person-name":"who"},"scope_strict":true}, + "calendar +free-slots": {"scoped_aliases":{"start-hour":"from","end-hour":"to","day-offset":"in-days","days-from-today":"in-days"},"block":["start","end","start-time","end-time","time-min","time-max","days","duration"],"scope_strict":true,"note":"--from/--to are integer hours and --in-days is a relative day offset; exact unit-preserving spellings are command-scoped while ISO timestamp and generic duration names stay blocked."}, + "calendar +freebusy": {"block":["who","with","name","names","attendee-names","participant-names","room-name","group-id"],"note":"This shortcut accepts stable userId/roomId lists; singular --room-id is one list element and normalizes safely, while names and room-search filters require different commands. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar +invite": {"scoped_aliases":{"id":"event"},"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids","calendar-id"],"scope_strict":true,"note":"--with is a name list resolved by the shortcut; stable person identifiers are not renamed into names."}, + "calendar +reschedule": {"scoped_aliases":{"id":"event","from":"start","begin":"start","start-time":"start","to":"end","end-time":"end"},"block":["calendar-id","calendar-book-id","acl-id","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The only identifier role is the eventId being rescheduled. Only explicit new-event-time spellings are mapped; range-bound or date-only spellings remain blocked."}, + "calendar +room-groups": {"scoped_aliases":{"page-index":"page"},"block":["cursor","page-token"],"scope_strict":true,"note":"This endpoint uses a zero-based pageIndex; cursor pagination cannot be converted to it."}, + "calendar +room-search": {"scoped_aliases":{"query":"room-name","name":"room-name"},"block":["room","room-id","room-ids","rooms","group-id","location"],"scope_strict":true,"note":"This shortcut accepts a room display-name keyword and returns roomId; identifier/location inputs are different roles."}, + "calendar +suggest-time": {"block":["user","user-id","user-ids","users","attendees","participants","open-dingtalk-ids"],"note":"--with carries names resolved by the shortcut; stable identifier lists belong to event suggest/freebusy. On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end.","scoped_aliases":{"to":"end"},"scope_strict":true}, + "calendar acl delete": {"block":["calendar-id","calendar-book-id","event","event-id","room-id","user-id"],"note":"--acl-id identifies one access-control grant, not a calendar, event, room, or user."}, + "calendar attachment add": {"block":["file","file-id","file-ids","attachments"],"note":"--files requires : elements; a bare file identifier cannot be wrapped or named by the alias layer."}, + "calendar attendee add": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."}, + "calendar attendee delete": {"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","open-dingtalk-ids"],"note":"--attendees carries a userId list; names require a resolver shortcut and openDingTalkIds are a distinct value domain."}, + "calendar busy search": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","room-name"],"note":"--users/--rooms are stable ID lists; singular --room-id safely denotes one list element, the existing hidden --room-ids compatibility stays native, and names require a resolver or room search."}, + "calendar event create": {"scoped_aliases":{"reminder-minutes":"remind-minutes","reminder-offset-minutes":"remind-minutes"},"block":["attendee-name","attendee-names","participant-name","participant-names","with","who","room-name","group-id","reminder-time","remind-at","at","due","duration"],"scope_strict":true,"note":"Creation accepts stable attendee/room identifiers and minute offsets; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native. Reminder aliases are exact to this command; names, room search criteria, absolute reminder times, and generic duration need separate resolution or value transforms."}, + "calendar event respond": {"scoped_aliases":{"response-status":"status","response":"status"},"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The response/status flag-name aliases are exact to this command. --status still uses this leaf's own attendee-response enum; values are not translated, and busy/free state or boolean completion names stay blocked."}, + "calendar event suggest": {"block":["who","with","name","names","attendee-name","attendee-names","participant-names","open-dingtalk-ids"],"note":"--users carries stable userIds; name resolution belongs to +suggest-time."}, + "calendar event update": {"block":["attendees","participants","rooms","room-id","room-ids","room-name","group-id","remind-minutes","reminder-minutes"],"note":"The command's existing hidden --freebusy compatibility flag remains native. Attendee, room, and reminder changes require dedicated commands or are unsupported by update."}, + "calendar room add": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."}, + "calendar room delete": {"block":["room","room-name","group-id","location"],"note":"--rooms is a roomId list returned by room search; singular --room-id safely denotes one list element and the existing hidden --room-ids compatibility stays native, while generic room, name, group, and location roles are not promoted automatically."}, + "calendar room search": {"scoped_aliases":{"room-group-id":"group-id","group":"group-id"},"block":["room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"On this exact command, room-group-id/group denote the meeting-room group filter; search consumes room name/group/time filters and returns roomId, while existing room identifiers and free-text event location are different roles."}, + "calendar +my-free": {"scoped_aliases":{"to":"end"},"scope_strict":true,"note":"On this exact command, --to is an unchanged ISO end timestamp and normalizes to --end."}, + "calendar +create": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","user-ids":"attendees","users":"attendees","attendee-ids":"attendees"},"block":["id","event","event-id","who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","group-id","rich-text-desc","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"The shortcut accepts stable attendee userId and roomId lists plus one calendarId; singular --room-id is one list element and normalizes safely. Names, openDingTalkIds, event identifiers, room-search filters, and HTML rich text are different roles; --to and --freebusy are exact value-preserving spellings on this command."}, + "calendar +get": {"ambiguous":["id"],"note":"The command carries one required eventId and one optional calendarId, so bare --id cannot safely select a role."}, + "calendar +room-find": {"scoped_aliases":{"to":"end","page-index":"page","room-group-id":"group-id","group":"group-id","query":"room-name","name":"room-name"},"block":["cursor","page-token","next-cursor","room","room-id","room-ids","rooms","location"],"scope_strict":true,"note":"This shortcut combines ISO time bounds, a zero-based pageIndex, and room-name/group filters. Cursor pagination and existing room identifiers cannot be converted to these filter roles."}, + "calendar +rsvp": {"scoped_aliases":{"response-status":"status","response":"status"},"ambiguous":["id"],"block":["state","done","free-busy","availability"],"scope_strict":true,"note":"The command carries eventId and calendarId together; bare --id is ambiguous. Response/status aliases are exact to this leaf, but --status still uses +rsvp's own enum values; values are never translated, and busy/free state names stay blocked."}, + "calendar +search-event": {"scoped_aliases":{"to":"end","search":"query"},"block":["id","event","event-id","page","page-index","offset"],"scope_strict":true,"note":"The query searches title, description, and location within one calendar page. This route uses cursor pagination; event identifiers and page-index/offset models are different roles."}, + "calendar +suggestion": {"scoped_aliases":{"to":"end"},"block":["who","with","name","names","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","room-name","room-id","room-ids","rooms"],"scope_strict":true,"note":"This shortcut accepts stable userId values. Person names belong to +suggest-time, openDingTalkIds are a different value domain, and room availability is a different query."}, + "calendar +update": {"scoped_aliases":{"from":"start","begin":"start","start-time":"start","to":"end","end-time":"end","freebusy":"free-busy","add-users":"add-attendees","add-user-ids":"add-attendees","remove-users":"remove-attendees","remove-user-ids":"remove-attendees"},"ambiguous":["id","attendees","users","user-ids"],"block":["who","with","attendee-name","attendee-names","participant-name","participant-names","open-dingtalk-ids","rooms","room-id","room-ids","room-name","group-id","rich-text-desc","remind-minutes","reminder-minutes","date","time","start-date","end-date","time-min","time-max"],"scope_strict":true,"note":"Bare --id cannot choose eventId versus calendarId, and a generic attendee list cannot choose add versus remove. Directional userId aliases are safe; names, openDingTalkIds, room changes, rich text, and reminders require different commands or capabilities."}, "chat +bot-find": {"scoped_aliases": {"name": "query"}, "scope_strict": true, "note": "On this exact bot-search shortcut, --name and --query denote the same search keyword; --name must not become a global search alias."}, "chat +chat-messages": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: --chat carries one stable openConversationId and normalizes to the existing --group identifier route."}, "chat +search-msg": {"scoped_aliases": {"chat": "group"}, "scope_strict": true, "note": "Evaluation compatibility: scalar --chat carries one stable openConversationId and normalizes to the existing scalar --group filter."}, @@ -114,7 +165,6 @@ "chat group-role update": {"block": ["role-ids"], "note": "This command requires one role id; list cardinality is not reduced automatically."}, "chat +chat-role-set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat group-role remove-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, - "chat group-role set-user": {"block": ["role-id"], "note": "This command requires a role-id list; one id is not promoted into a batch input."}, "chat +messages-send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact shortcut."}, "chat message send-by-webhook": {"scoped_aliases": {"at-user-ids": "at-users"}, "scope_strict": true, "note": "Both names denote the same userId list used for @ mentions on this exact command."}, "doc block insert": {"block": ["before-block-id"], "note": "Parent and reference roles remain distinct. --before-block-id needs both --ref-block and --where before, while role-free --block-id cannot choose parent versus reference.", "scoped_aliases": {"parent-block-id": "parent-block", "ref-block-id": "ref-block", "reference-block-id": "ref-block"}, "ambiguous": ["block-id"], "scope_strict": true}, @@ -265,7 +315,115 @@ "aitable +import-upload": {"scoped_aliases":{"size":"file-size"},"scope_strict":true,"note":"On this import-upload command --file-size is the local file size in bytes; the mapping is command-local because size is a pagination synonym elsewhere."}, "aitable +table-copy": {"scoped_aliases":{"table-id":"source-table-id"},"ambiguous":["base","base-id","base-token"],"scope_strict":true,"note":"The only existing table role is the source table, while an unqualified Base spelling cannot choose between source-base-id and target-base-id."}, "aitable workflow run": {"block":["record-id"],"note":"The command accepts a list of one to five record IDs. A singular --record-id is deliberately blocked instead of relying on edit-distance correction; --table-id and --record-ids must remain paired for record-triggered workflows."}, - "aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."} + "aitable workflow history": {"scoped_aliases":{"start-time":"after-time","end-time":"before-time","page-index":"page"},"block":["cursor","offset","page-token","next-cursor","next-token","page-no","page-number","current-page"],"scope_strict":true,"note":"This endpoint uses a zero-based page plus per-page size, not cursor/offset pagination. Time-bound aliases preserve the required Unix-millisecond value unchanged."}, + "minutes +share": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","policy"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. Semantic permission names and numeric policy values require value conversion and are intentionally blocked across surfaces. The broad real --id is explicitly bound to the Minutes taskUuid concept."}, + "minutes +unshare": {"bind":{"member-uids":"user_ids","id":"minutes_task_uuid"},"block":["user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--member-uids is a DingTalk userId list; singular inputs and staffId/unionId/openDingTalkId value domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept."}, + "minutes permission add": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id","permission"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task IDs, singular member IDs, staffId/unionId/openDingTalkId domains, and semantic permission names are not promoted into these list/numeric roles."}, + "minutes permission remove": {"bind":{"member-uids":"user_ids","ids":"minutes_task_uuids"},"block":["id","task-uuid","uuid","user","user-id","userid","uid","staff-id","union-id","open-dingtalk-id"],"note":"--ids is a taskUuid list and --member-uids is a DingTalk userId list. Singular task/member inputs and staffId/unionId/openDingTalkId domains are not promoted."}, + "minutes speaker replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +speaker-replace": {"scoped_aliases":{"user-id":"target-uid","userid":"target-uid","uid":"target-uid","old-speaker":"from","source-speaker":"from","new-speaker":"to","target-speaker":"to"},"block":["target-uids","user","staff-id","union-id","open-dingtalk-id"],"scope_strict":true,"note":"--id is taskUuid, --from/--to are speaker-label roles, and singular --target-uid requires one stable DingTalk userId. Plural target IDs and other user identity domains are not promoted. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes audio-memo list": {"scoped_aliases":{"limit":"max","page-size":"max","max-results":"max","next-cursor":"cursor"},"block":["next-token","page-token","next-page-token"],"scope_strict":true,"note":"This command uses --max for the count bound and an integer cursor; opaque pagination tokens cannot be renamed into that integer domain."}, + "minutes tag query": {"scoped_aliases":{"id":"tag-id"},"block":["tag-ids","group-id","group-name"],"scope_strict":true,"note":"The generic identifier on this exact command is one Minutes tag ID, not a taskUuid, tag list, or group identifier."}, + "minutes replace-text": {"scoped_aliases":{"find":"search","original-text":"search","new-text":"replace","replacement-text":"replace"},"block":["text","content","body"],"scope_strict":true,"note":"Search and replacement are two distinct required roles; role-free text spellings cannot safely choose one side. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +replace-batch": {"scoped_aliases":{"pairs":"pair"},"block":["search","replace","original-text","replacement-text"],"scope_strict":true,"note":"The repeatable --pair value is encoded as source=>replacement; name normalization cannot compose it from two separate values. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +download": {"ambiguous":["destination","path","save-path"],"note":"The destination role depends on singular --output versus batch --output-dir and cannot be selected from a generic path name. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes list all": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes list mine": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes list shared": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes +search": {"block":["date-range"],"note":"A date range must be represented by both --start and --end; name-only normalization cannot split one value into two flags."}, + "minutes +apply-permission": {"block":["policy"],"note":"Semantic permission names and numeric policy values require value conversion and cannot be passed through by renaming. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes permission apply": {"block":["ids","task-uuids","uuids","permission"],"note":"This leaf accepts one taskUuid and a numeric policy. Task lists and semantic permission names require cardinality/value conversion and cannot be passed through by renaming."}, + "minutes get info": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get summary": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get keywords": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get transcription": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get todos": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes get audio": {"block":["ids","task-uuids","uuids"],"note":"This leaf accepts exactly one taskUuid; list spellings must stop before the hidden singular aliases or trailing-id tolerance can consume a comma-separated value."}, + "minutes mind-graph create": {"block":["ids","task-uuids","uuids"],"note":"This leaf creates a mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."}, + "minutes mind-graph status": {"block":["ids","task-uuids","uuids"],"note":"This leaf queries the mind graph for exactly one taskUuid; a task list cannot be renamed into the singular role."}, + "minutes upload create": {"block":["file","file-path","path","template-ids"],"note":"The atomic create command requires file-name, byte-sized file-size, and at most one template ID. A local path needs stat/upload orchestration, and a template list cannot be renamed into the singular role."}, + "minutes upload complete": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command completes exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."}, + "minutes upload cancel": {"block":["session-ids","upload-session-ids","upload-ids"],"note":"The command cancels exactly one upload session; plural session spellings cannot be renamed into its singular sessionId."}, + "minutes +upload": {"block":["file-name","file-size","size-bytes","template-ids"],"note":"The Shortcut requires a local --file path, computes file metadata, and accepts at most one template ID; atomic metadata or template lists cannot be renamed into those roles."}, + "minutes record start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."}, + "minutes record pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes record resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes record stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-start": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. Plural or upload-session spellings are blocked."}, + "minutes +record-pause": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-resume": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +record-stop": {"block":["session-ids","upload-session-id","upload-session-ids","upload-id","upload-ids"],"note":"The optional recording assistant session is singular, and recording/upload session IDs are different business entities. The broad real --id is explicitly bound to the Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +speaker-insights": {"block":["task-ids"],"note":"--id is one Minutes taskUuid while optional --task-id is one asynchronous recovery handle; neither role accepts a list. The broad real --id is explicitly bound to the single Minutes taskUuid concept.","bind":{"id":"minutes_task_uuid"}}, + "minutes +upload-and-analyze": {"scoped_aliases":{"task-uuid":"resume-id","uuid":"resume-id"},"block":["file-name","file-size","size-bytes","id","resume-ids","template-ids"],"scope_strict":true,"note":"--resume-id is one already-created taskUuid and --template-id is singular. Explicit taskUuid spellings may resume one task, while generic --id, list spellings, and atomic file metadata remain blocked against route/cardinality confusion."}, + "minutes +action-items": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +detail": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +export-pack": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +mindmap": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +record-wrap-up": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +transcript": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes +update": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes update summary": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "minutes update title": {"bind":{"id":"minutes_task_uuid"},"note":"The broad real --id is explicitly bound to the single Minutes taskUuid concept."}, + "todo task create-sub": {"bind":{"parent-id":"todo_task_id"},"scoped_aliases":{"parent-task-id":"parent-id"},"ambiguous":["task"],"scope_strict":true,"note":"This command has a parent taskId and a new child title. Reviewed task-id/todo-id spellings reduce to --parent-id, while role-free --task cannot choose between the parent target and child title and must stop before execution."}, + "todo task update": {"ambiguous":["task"],"note":"This command has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."}, + "todo +update": {"ambiguous":["task"],"note":"This shortcut has both --task-id and --title. Role-free --task cannot safely choose the existing target identifier or replacement title."}, + "todo +todo-done": {"scoped_aliases":{"query":"task","keyword":"task","title":"task"},"block":["task-id","todo-id"],"scope_strict":true,"note":"The real --task is a title substring query, not taskId. Query/title spellings preserve the search text; stable IDs must use todo task done."}, + "todo task create": {"scoped_aliases":{"task":"title"},"note":"This creation command has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."}, + "todo +create": {"scoped_aliases":{"task":"title"},"note":"This creation shortcut has one task-title role, so the exact --task spelling can preserve the title text without competing with an existing target taskId."}, + "todo +assign": {"scoped_aliases":{"assignee-name":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves one human name from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."}, + "todo +assign-multi": {"scoped_aliases":{"assignee-names":"to","title":"task"},"block":["executor-id","executor-ids","executors","user-id","user-ids"],"note":"This shortcut resolves a comma-separated list of human names from --to and creates one title from --task. Exact name/title spellings preserve those roles; stable userId spellings must not be renamed into a name-resolution input."}, + "todo +remind": {"scoped_aliases":{"deadline":"at","due":"at","due-time":"at","title":"task"},"block":["remind-at","reminder-at","reminder-rules","reminder-time","reminder-time-stamp"],"scope_strict":true,"note":"This shortcut has one title role and its real --at writes the task deadline dueTime. Exact title/deadline spellings preserve those values; independent reminder spellings are rejected and must route to todo +reminder or todo task add-reminder/reset-reminder."}, + "todo +search": {"scoped_aliases":{"subject":"query","title":"query","task":"query"},"block":["limit","max-result","max-results","page","page-size","per-page","size","take","top"],"note":"This shortcut searches the task card subject/title substring across complete traversal. Subject/title/task spellings preserve query text; result-count and page-size names are not aliases for the real --max-pages safety cap."}, + "todo +complete": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +complete command. A status flag would falsely imply caller-selectable state; use +reopen or todo task done for the opposite state."}, + "todo +reopen": {"block":["done","is-done","status","todo-status"],"note":"The target state is fixed by the +reopen command. A status flag would falsely imply caller-selectable state; use +complete or todo task done for the opposite state."}, + "todo +reminder": {"scoped_aliases":{"remind-at":"at","reminder-at":"at","reminder-time":"at","reminder-time-stamp":"at"},"block":["deadline","due","due-time","reminder-rules"],"note":"The real --at is the ISO-8601 customTime reminder timestamp. Reminder-time spellings preserve that value; task deadlines and a JSON reminder-rules array are different roles or structures."}, + "todo task add-reminder": {"scoped_aliases":{"at":"reminder-time-stamp","reminder-at":"reminder-time-stamp","reminder-time":"reminder-time-stamp"},"block":["deadline","due","due-time","reminder-rules"],"note":"This command writes one independent reminder rule. --at is the same ISO-8601 customTime value as --reminder-time-stamp; a task deadline or JSON rules array cannot be reduced to one atomic reminder field. The inherited real --remind-at remains command-owned and cannot be intercepted by PreParse."}, + "todo task reset-reminder": {"block":["at","base-time","deadline","due","due-date-offset","due-time","reminder-time-stamp"],"note":"This command accepts one --reminder-rules JSON array. Atomic reminder fields or task deadlines require value construction and are not name-only aliases. The inherited real --remind-at cannot be blocked by PreParse."}, + "todo task list": {"scoped_aliases":{"limit":"size","max-results":"size","page-number":"page","priorities":"priority"},"block":["plan-finish-end","plan-finish-start","priority-id","priority-name"],"scope_strict":true,"note":"Here --size is the total returned-item target and auto-pages beyond 20, so only total-limit spellings are mapped. --page-number preserves the one-based page value. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The sibling shortcut's plan-finish-start/end use Unix milliseconds and are blocked because this command requires ISO-8601 date strings."}, + "todo +get-my-tasks": {"scoped_aliases":{"page-number":"page","page-size":"size","per-page":"size","priorities":"priority"},"block":["limit","max-result","max-results","plan-finish-date-end","plan-finish-date-start","priority-id","priority-name","take","top"],"note":"This shortcut's --size is per-page, and --all ignores pageSize; only explicit per-page spellings are aliases. Total-limit spellings are blocked. The list-only --priorities spelling preserves the unchanged 10/20/30/40 filter values without claiming a global priority concept. The command requires Unix millisecond plan-finish-start/end values, so the stable command's date-named ISO-8601 flags cannot be converted by name-only normalization."}, + "todo +list-comment": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."}, + "todo comment list": {"scoped_aliases":{"page-number":"page"},"note":"The explicit page-number spelling preserves the one-based page value; page-size remains handled by the reviewed pagination-size concept."}, + "todo +get-related-tasks": {"scoped_aliases":{"todo-status":"status"},"block":["done","is-done"],"note":"This shortcut accepts the service enum status value such as TODO, not a boolean completion flag. The product-qualified spelling is safe; boolean spellings are rejected."}, + "todo tag add": {"block":["tag-ids","tag-names","tags"],"note":"The command requires existing tag code values. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."}, + "todo tag delete": {"block":["tag-ids","tag-names","tags"],"note":"The command deletes tag definitions by existing tag code. Generic tags may be names or IDs and are not safely equivalent to --tag-codes."}, + "todo tag update": {"block":["tag-codes","tag-ids","tag-names","tags"],"note":"The real --user-tags is a JSON array of tag objects. A flat list of codes, names, or IDs cannot be renamed into that structure."}, + "wiki +resolve-space": {"bind":{"name":"search_query"},"scoped_aliases":{"knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--name is a knowledge-space name search string, not workspaceId. Search spellings preserve the unchanged text; ID spellings stop before lookup."}, + "wiki +wiki-new-doc": {"scoped_aliases":{"doc-title":"title","document-title":"title","knowledge-base-name":"space","space-name":"space","wiki-name":"space","workspace-name":"space"},"ambiguous":["name"],"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"--space is a knowledge-space name search string and --title is the new document title. Role-free --name cannot choose between them, and workspace/storage IDs are not accepted by this name-resolving shortcut."}, + "wiki +node-get": {"scoped_aliases":{"doc-id":"node","document-id":"node","document-url":"node","id":"node","node-id":"node","url":"node","wiki-node-id":"node"},"block":["dentry-id","folder","folder-id","knowledge-base-id","space-id","workspace","workspace-id"],"scope_strict":true,"note":"This exact shortcut has one Wiki node target and explicitly accepts nodeId or URL. Numeric dentryId, folder and workspace identifiers are different roles."}, + "wiki +node-copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before copy."}, + "wiki +move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Source node, destination workspace and destination folder are separate ID roles. --node-id and explicit source/target spellings preserve values; role-free target IDs stop before move."}, + "wiki +move-to-drive": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","folder-id":"folder","node-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"The source is one Wiki node while --folder is a Drive destination folder. --node-id preserves the source value; workspace and storage-space identifiers are not substitutes for either role."}, + "wiki +node-delete": {"scoped_aliases":{"document-id":"node","node-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The destructive target node and the workspace confirmation scope are distinct required IDs. --node-id is explicit and value-preserving; generic --id cannot choose one and must stop before confirmation."}, + "wiki node copy": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."}, + "wiki node move": {"scoped_aliases":{"destination-folder-id":"folder","document-id":"node","source-document-id":"node","source-node-id":"node","target-folder-id":"folder","wiki-node-id":"node"},"ambiguous":["destination-id","id","target-id"],"block":["dentry-id","source-folder-id","space-id"],"scope_strict":true,"note":"Native hidden node/folder/workspace compatibility flags remain native. These additional role-qualified spellings preserve the same values; generic target IDs remain ambiguous."}, + "wiki node delete": {"scoped_aliases":{"document-id":"node","target-node-id":"node","wiki-node-id":"node"},"ambiguous":["id"],"block":["dentry-id","folder-id","space-id"],"scope_strict":true,"note":"The node target and workspace authorization scope are distinct IDs. Native hidden node/workspace aliases remain native; generic --id must stop before deletion."}, + "wiki +node-list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder","next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","parent-folder-id":"folder","parent-node-id":"folder","per-page":"limit"},"ambiguous":["id","max-result","max-results","size","take","top"],"block":["dentry-id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Workspace and optional parent folder are separate IDs. Per-page --limit and aggregate --max-items are separate limits; generic maximum spellings cannot choose one."}, + "wiki +node-create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","parent-folder-id":"folder","parent-node-id":"folder","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Workspace, parent folder, node display name and node type are distinct roles. Node-type aliases stay local; search and space-type spellings are rejected."}, + "wiki node list": {"scoped_aliases":{"directory-id":"folder","folder-id":"folder"},"ambiguous":["id"],"block":["dentry-id","max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden parent/pagination/workspace aliases remain native. This leaf is single-page; Shortcut auto-page controls must not be accepted on the native command."}, + "wiki node create": {"scoped_aliases":{"directory-id":"folder","display-name":"name","folder-id":"folder","node-name":"name","node-type":"type"},"ambiguous":["id"],"block":["dentry-id","node-id","query","space-id","space-type","wiki-space-type"],"scope_strict":true,"note":"Native hidden parent/workspace aliases remain native. Workspace, parent folder, display name and node type remain distinct roles."}, + "wiki +node-search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"The command searches within one workspace by unchanged keyword text. A node ID or role-free name is not a workspace-scoped search parameter."}, + "wiki node search": {"scoped_aliases":{"node-name":"query"},"block":["id","name","node-id","offset","page","space-id"],"scope_strict":true,"note":"Native hidden query/pagination/workspace aliases remain native. Search text, workspace and node identifiers are distinct roles."}, + "wiki +space-create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"This creates a new knowledge-base container. Name and plain description aliases preserve text; existing workspace IDs, search queries and node titles are different roles."}, + "wiki space create": {"scoped_aliases":{"display-name":"name","knowledge-base-name":"name","space-name":"name","wiki-name":"name","workspace-name":"name"},"block":["node-name","query","title","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden --description remains native. This command creates a container; existing workspace IDs, search queries and node titles are not creation names."}, + "wiki +space-list": {"scoped_aliases":{"next-cursor":"cursor","next-page-token":"cursor","next-token":"cursor","page-size":"limit","per-page":"limit","space-type":"type","wiki-space-type":"type"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","knowledge-base-id","node-type","offset","page","space","space-id","storage-space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut lists only orgWikiSpace/myWikiSpace and has separate per-page and aggregate limits. Drive storage types belong to native wiki space list."}, + "wiki space list": {"scoped_aliases":{"space-type":"type"},"block":["id","knowledge-base-id","max-items","node-type","offset","page","page-all","page-delay","page-limit","space","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This composite native leaf can list Wiki or Drive storage spaces by --type but is single-page. A target workspace ID and Shortcut auto-page controls are not accepted."}, + "wiki +space-search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","wiki-name":"query","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","space-type","type","wiki-space-type","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"This Shortcut searches organization knowledge bases by keyword and does not expose native --type myWikiSpace routing. IDs and type selectors must not be rewritten into query text."}, + "wiki space search": {"scoped_aliases":{"knowledge-base-name":"query","space-name":"query","space-type":"type","wiki-name":"query","wiki-space-type":"type","workspace-name":"query"},"block":["id","knowledge-base-id","space-id","wiki-workspace-id","workspace","workspace-id"],"scope_strict":true,"note":"Native hidden query/pagination aliases remain native. Name spellings are unchanged search text, while --type myWikiSpace is a distinct route selector."}, + "wiki +space-get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"This exact command has one knowledge-base workspace target, so role-free --id is safe locally. Drive storage-space, node and folder IDs remain separate domains."}, + "wiki space get": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. This exact leaf has one knowledge-base workspace target; other ID domains are blocked."}, + "wiki +delete-space": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"The destructive command has one confirmed knowledge-base workspace target. Drive storage-space, node and folder IDs must stop before confirmation."}, + "wiki space delete": {"scoped_aliases":{"id":"workspace"},"block":["folder-id","node-id","space-id","storage-space-id"],"scope_strict":true,"note":"Native --workspace-id remains a hidden compatibility flag. The destructive target is one knowledge-base workspace, not a Drive space or child node."}, + "wiki +feed-list": {"scoped_aliases":{"page-size":"limit","per-page":"limit"},"ambiguous":["max-result","max-results","size","take","top"],"block":["id","node-id","offset","page","space-id"],"scope_strict":true,"note":"Per-page --limit and aggregate --max-items are separate bounds. Feed scope is one workspace; node and storage-space IDs are different roles."}, + "wiki feed list": {"scoped_aliases":{"id":"workspace"},"block":["max-items","node-id","offset","page","page-all","page-delay","page-limit","space-id"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. This leaf is single-page, and Shortcut auto-page controls are not accepted."}, + "wiki +member-add": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member add": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-update": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member update": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-remove": {"scoped_aliases":{"uid":"user","user-id":"user"},"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Workspace and a 1-30 item userId list are separate ID roles. --user-id/--uid value-preservingly normalize to the real repeatable --user alias; openDingTalkId and staffId spellings remain blocked."}, + "wiki member remove": {"ambiguous":["id"],"block":["name","open-dingtalk-id","open-dingtalk-ids","staff-id"],"scope_strict":true,"note":"Only canonical --users and the manually wired hidden --user are runtime-consumed. Auto-registered real hidden --user-id/--uid/--user-ids/--user-list/--user-id-list bypass central rewriting but are not read by collectUserIDs; use --users until command-owned wiring is fixed."}, + "wiki +member-list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"This command lists members and can filter by a role list. It does not accept user filters or cursor auto-pagination, and workspace/user IDs must not be conflated."}, + "wiki member list": {"scoped_aliases":{"filter-roles":"filter-role","role-filter":"filter-role","roles":"filter-role"},"ambiguous":["id"],"block":["cursor","name","open-dingtalk-id","page-all","role","user-id","user-ids","users"],"scope_strict":true,"note":"Native hidden workspace/pagination aliases remain native. The endpoint has no user filter or cursor and returns at most 50 members."} }, "validation_fixture": { @@ -300,7 +458,6 @@ {"command": "drive list", "emitted": "folder-id", "expect": "folder", "via": "concept:folder_id+morph", "occ": 2}, {"command": "mail thread list", "emitted": "max-results", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "mail user search", "emitted": "query", "expect": "keyword", "via": "concept:search_query", "occ": 2}, - {"command": "oa +list-executed", "emitted": "take", "expect": "limit", "via": "concept:pagination_size", "occ": 2}, {"command": "oa approval search-forms", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 2}, {"command": "report list", "emitted": "from-date", "expect": "start", "via": "concept:time_start", "occ": 2}, {"command": "aitable +base-search", "emitted": "keyword", "expect": "query", "via": "concept:search_query", "occ": 1}, @@ -397,7 +554,6 @@ {"command": "chat message send", "emitted": "file-path", "expect": "file", "via": "native:reviewed-compatibility-alias"}, {"command": "chat category add-conv", "emitted": "category-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat category rename", "emitted": "category-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, - {"command": "chat group-role set-user", "emitted": "role-id", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat group-role update", "emitted": "role-ids", "expect": "did-you-mean:blocked", "via": "override:block-cardinality"}, {"command": "chat message send-by-webhook", "emitted": "at-user-ids", "expect": "at-users", "via": "override:scoped-role"}, {"command": "chat message send-by-bot", "emitted": "at-users", "expect": "at-user-ids", "via": "override:scoped-role"}, @@ -695,7 +851,258 @@ {"command":"aitable workflow history","emitted":"page-index","expect":"page","via":"override:scoped-zero-based-page"}, {"command":"aitable workflow history","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-vs-cursor-model"}, {"command":"aitable workflow history","emitted":"offset","expect":"did-you-mean:blocked","via":"guard:page-vs-offset-model"}, - {"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"} + {"command":"aitable workflow history","emitted":"page-number","expect":"did-you-mean:blocked","via":"guard:zero-vs-one-based-page"}, + {"command":"calendar +agenda","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +agenda","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +agenda","emitted":"max-results","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +agenda","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"calendar +agenda","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +agenda","emitted":"id","expect":"did-you-mean:blocked","via":"guard:calendar-id-vs-event-id"}, + {"command":"calendar +attendee-list","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +attendee-list","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +attendee-list","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +book","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +book","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +book","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +book-search","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"calendar +book-search","emitted":"search","expect":"query","via":"override:scoped_strict"}, + {"command":"calendar +book-search","emitted":"name","expect":"query","via":"override:scoped_strict"}, + {"command":"calendar +cancel-event","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +cancel-event","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +cancel-event","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +free","emitted":"name","expect":"who","via":"override:scoped_strict"}, + {"command":"calendar +free","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +free-slots","emitted":"start-hour","expect":"from","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"end-hour","expect":"to","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"}, + {"command":"calendar +free-slots","emitted":"start","expect":"did-you-mean:blocked","via":"guard:hour-vs-iso-time"}, + {"command":"calendar +freebusy","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"calendar +freebusy","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +freebusy","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +freebusy","emitted":"with","expect":"did-you-mean:blocked","via":"guard:id-list-vs-name-list"}, + {"command":"calendar +my-free","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +my-free","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +invite","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +invite","emitted":"participant-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +invite","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +reschedule","emitted":"id","expect":"event","via":"override:scoped_strict"}, + {"command":"calendar +reschedule","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +reschedule","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +room-groups","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +room-groups","emitted":"page-index","expect":"page","via":"override:scoped_strict"}, + {"command":"calendar +room-groups","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"}, + {"command":"calendar +room-search","emitted":"query","expect":"room-name","via":"override:scoped_strict"}, + {"command":"calendar +room-search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar +suggest-time","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar +suggest-time","emitted":"attendee-names","expect":"with","via":"concept:calendar_person_name_list"}, + {"command":"calendar +suggest-time","emitted":"user-ids","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +conflicts","emitted":"day-offset","expect":"in-days","via":"override:scoped_strict"}, + {"command":"calendar attachment add","emitted":"file-id","expect":"did-you-mean:blocked","via":"guard:structured-file-list"}, + {"command":"calendar attendee add","emitted":"attendee-name","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar busy search","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar busy search","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar event create","emitted":"reminder-minutes","expect":"remind-minutes","via":"override:scoped_strict"}, + {"command":"calendar event create","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar event create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar event create","emitted":"room-name","expect":"did-you-mean:blocked","via":"guard:room-name-vs-room-id"}, + {"command":"calendar event respond","emitted":"response-status","expect":"status","via":"override:scoped_strict"}, + {"command":"calendar event respond","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"}, + {"command":"calendar event suggest","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar event suggest","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar event update","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar event update","emitted":"rooms","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"}, + {"command":"calendar room add","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar room delete","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar room search","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"}, + {"command":"calendar room search","emitted":"room-id","expect":"did-you-mean:blocked","via":"guard:room-filter-vs-room-id"}, + {"command":"calendar acl delete","emitted":"calendar-id","expect":"did-you-mean:blocked","via":"guard:acl-id-vs-calendar-id"}, + {"command":"calendar +create","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +create","emitted":"description","expect":"desc","via":"concept:plain_description"}, + {"command":"calendar +create","emitted":"user-ids","expect":"attendees","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"room-ids","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +create","emitted":"room-id","expect":"rooms","via":"concept:calendar_room_ids"}, + {"command":"calendar +create","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +create","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +create","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +get","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +get","emitted":"calendar-book-id","expect":"calendar-id","via":"concept:calendar_book_id"}, + {"command":"calendar +get","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +room-find","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +room-find","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +room-find","emitted":"page-index","expect":"page","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"room-group-id","expect":"group-id","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"query","expect":"room-name","via":"override:scoped_strict"}, + {"command":"calendar +room-find","emitted":"cursor","expect":"did-you-mean:blocked","via":"guard:page-index-vs-cursor"}, + {"command":"calendar +rsvp","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +rsvp","emitted":"response-status","expect":"status","via":"override:scoped_strict"}, + {"command":"calendar +rsvp","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +rsvp","emitted":"free-busy","expect":"did-you-mean:blocked","via":"guard:response-vs-busy-state"}, + {"command":"calendar +search-event","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"calendar +search-event","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +search-event","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +search-event","emitted":"next-cursor","expect":"cursor","via":"concept:page_cursor"}, + {"command":"calendar +search-event","emitted":"max-results","expect":"limit","via":"concept:pagination_size"}, + {"command":"calendar +search-event","emitted":"page","expect":"did-you-mean:blocked","via":"guard:cursor-vs-page-index"}, + {"command":"calendar +suggestion","emitted":"user-ids","expect":"users","via":"concept:user_ids"}, + {"command":"calendar +suggestion","emitted":"duration-minutes","expect":"duration","via":"concept:calendar_duration_minutes"}, + {"command":"calendar +suggestion","emitted":"from","expect":"start","via":"concept:time_start"}, + {"command":"calendar +suggestion","emitted":"to","expect":"end","via":"override:scoped_strict"}, + {"command":"calendar +suggestion","emitted":"tz","expect":"timezone","via":"concept:calendar_timezone"}, + {"command":"calendar +suggestion","emitted":"attendee-names","expect":"did-you-mean:blocked","via":"guard:name-vs-user-id"}, + {"command":"calendar +update","emitted":"event-id","expect":"event","via":"concept:calendar_event_id"}, + {"command":"calendar +update","emitted":"from","expect":"start","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"summary","expect":"title","via":"concept:calendar_event_title"}, + {"command":"calendar +update","emitted":"description","expect":"desc","via":"concept:plain_description"}, + {"command":"calendar +update","emitted":"add-user-ids","expect":"add-attendees","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"remove-user-ids","expect":"remove-attendees","via":"override:scoped_strict"}, + {"command":"calendar +update","emitted":"id","expect":"did-you-mean:ambiguous","via":"guard:event-id-vs-calendar-id"}, + {"command":"calendar +update","emitted":"attendees","expect":"did-you-mean:ambiguous","via":"guard:add-vs-remove-role"}, + {"command":"calendar +update","emitted":"room-ids","expect":"did-you-mean:blocked","via":"guard:dedicated-room-command"}, + {"command":"minutes update title","emitted":"task-uuid","expect":"id","via":"concept:minutes_task_uuid"}, + {"command":"minutes +record-pause","emitted":"uuid","expect":"id","via":"concept:minutes_task_uuid"}, + {"command":"minutes +latest","emitted":"query","expect":"keyword","via":"concept:search_query"}, + {"command":"minutes +transcript","emitted":"query","expect":"keyword","via":"concept:search_query+mutually-exclusive-id"}, + {"command":"minutes +detail","emitted":"task-uuids","expect":"ids","via":"concept:minutes_task_uuids"}, + {"command":"minutes get batch","emitted":"uuids","expect":"ids","via":"concept:minutes_task_uuids"}, + {"command":"minutes +list-all","emitted":"page-size","expect":"limit","via":"concept:pagination_size"}, + {"command":"minutes +search","emitted":"next-token","expect":"cursor","via":"concept:page_cursor"}, + {"command":"minutes list mine","emitted":"start-time","expect":"start","via":"concept:time_start"}, + {"command":"minutes list all","emitted":"end-time","expect":"end","via":"concept:time_end"}, + {"command":"minutes +summary","emitted":"text","expect":"content","via":"concept:content_text"}, + {"command":"minutes hot-word add","emitted":"hot-words","expect":"words","via":"concept:minutes_hot_words"}, + {"command":"minutes upload complete","emitted":"upload-id","expect":"session-id","via":"concept:minutes_upload_session_id"}, + {"command":"minutes +share","emitted":"user-ids","expect":"member-uids","via":"override:bind-user-id-list"}, + {"command":"minutes +share","emitted":"user-id","expect":"did-you-mean:blocked","via":"guard:member-list-cardinality"}, + {"command":"minutes +speaker-replace","emitted":"user-id","expect":"target-uid","via":"override:bind-target-user"}, + {"command":"minutes +speaker-replace","emitted":"staff-id","expect":"did-you-mean:blocked","via":"guard:user-id-value-domain"}, + {"command":"minutes audio-memo list","emitted":"limit","expect":"max","via":"override:scoped-page-size"}, + {"command":"minutes audio-memo list","emitted":"next-token","expect":"did-you-mean:blocked","via":"guard:integer-cursor-vs-token"}, + {"command":"minutes tag query","emitted":"id","expect":"tag-id","via":"override:scoped-tag-id"}, + {"command":"minutes replace-text","emitted":"original-text","expect":"search","via":"override:scoped-search-role"}, + {"command":"minutes replace-text","emitted":"text","expect":"did-you-mean:blocked","via":"guard:search-vs-replacement-role"}, + {"command":"minutes +replace-batch","emitted":"pairs","expect":"pair","via":"override:scoped-pair-cardinality"}, + {"command":"minutes +replace-batch","emitted":"search","expect":"did-you-mean:blocked","via":"guard:requires-pair-value-composition"}, + {"command":"minutes +download","emitted":"destination","expect":"did-you-mean:ambiguous","via":"guard:output-role"}, + {"command":"minutes +detail","emitted":"url","expect":"did-you-mean:blocked","via":"guard:url-needs-value-extraction"}, + {"command":"minutes +apply-permission","emitted":"policy","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"}, + {"command":"minutes permission apply","emitted":"permission","expect":"did-you-mean:blocked","via":"guard:permission-value-conversion"}, + {"command":"minutes upload create","emitted":"file","expect":"did-you-mean:blocked","via":"guard:file-path-needs-orchestration"}, + {"command":"minutes +upload","emitted":"file-size","expect":"did-you-mean:blocked","via":"guard:file-metadata-vs-path"}, + {"command":"minutes record start","emitted":"upload-id","expect":"did-you-mean:blocked","via":"guard:session-value-domain"}, + {"command":"minutes +upload-and-analyze","emitted":"task-uuid","expect":"resume-id","via":"override:resume-task-uuid"}, + {"command":"minutes get info","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get summary","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get keywords","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get transcription","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get todos","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes get audio","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes mind-graph create","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes mind-graph status","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes permission apply","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-uuid"}, + {"command":"minutes permission add","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"}, + {"command":"minutes permission remove","emitted":"id","expect":"did-you-mean:blocked","via":"guard:list-vs-single-task-uuid"}, + {"command":"minutes upload complete","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"}, + {"command":"minutes upload cancel","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-upload-session"}, + {"command":"minutes record start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes record stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-start","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-pause","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-resume","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes +record-stop","emitted":"session-ids","expect":"did-you-mean:blocked","via":"guard:single-recording-session"}, + {"command":"minutes speaker replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"}, + {"command":"minutes +speaker-replace","emitted":"target-uids","expect":"did-you-mean:blocked","via":"guard:single-target-user"}, + {"command":"minutes tag query","emitted":"tag-ids","expect":"did-you-mean:blocked","via":"guard:single-tag"}, + {"command":"minutes +speaker-insights","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-async-task"}, + {"command":"minutes +upload-and-analyze","emitted":"resume-ids","expect":"did-you-mean:blocked","via":"guard:single-resume-task"}, + {"command":"minutes upload create","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"minutes +upload","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"minutes +upload-and-analyze","emitted":"template-ids","expect":"did-you-mean:blocked","via":"guard:single-template"}, + {"command":"todo task get","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo task get","emitted":"ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task get","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task get","emitted":"todo-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-task-id"}, + {"command":"todo task create-sub","emitted":"task-id","expect":"parent-id","via":"concept:todo_task_id+bind"}, + {"command":"todo task create-sub","emitted":"task-ids","expect":"did-you-mean:blocked","via":"guard:single-vs-list-parent-task-id"}, + {"command":"todo task create-sub","emitted":"parent-task-id","expect":"parent-id","via":"override:parent-task-id"}, + {"command":"todo task create-sub","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:parent-target-vs-child-title"}, + {"command":"todo task create","emitted":"task","expect":"title","via":"override:creation-title"}, + {"command":"todo +create","emitted":"task","expect":"title","via":"override:creation-title"}, + {"command":"todo +assign","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo +assign-multi","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo +assign","emitted":"assignee-name","expect":"to","via":"override:human-name"}, + {"command":"todo +assign-multi","emitted":"assignee-names","expect":"to","via":"override:human-name-list"}, + {"command":"todo +update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"}, + {"command":"todo task update","emitted":"task","expect":"did-you-mean:ambiguous","via":"guard:target-id-vs-title"}, + {"command":"todo +remind","emitted":"due","expect":"at","via":"override:deadline-to-at"}, + {"command":"todo +remind","emitted":"title","expect":"task","via":"override:creation-title"}, + {"command":"todo task create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"}, + {"command":"todo +create","emitted":"deadline","expect":"due","via":"concept:todo_due_time"}, + {"command":"todo task add-executor","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"}, + {"command":"todo task add-executor","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-executor"}, + {"command":"todo task add-executor","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:executor-vs-participant-role"}, + {"command":"todo +create","emitted":"executor-ids","expect":"executors","via":"concept:todo_executor_ids"}, + {"command":"todo task add-participant","emitted":"participant-ids","expect":"participants","via":"concept:todo_participant_ids"}, + {"command":"todo task add-participant","emitted":"participant-id","expect":"did-you-mean:blocked","via":"guard:single-vs-list-participant"}, + {"command":"todo task add-participant","emitted":"executor-id","expect":"did-you-mean:blocked","via":"guard:participant-vs-executor-role"}, + {"command":"todo task update","emitted":"status","expect":"done","via":"concept:todo_completion_state"}, + {"command":"todo task update","emitted":"is-done","expect":"done","via":"concept:todo_completion_state"}, + {"command":"todo task done","emitted":"done","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +search","emitted":"done","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +search","emitted":"todo-status","expect":"status","via":"concept:todo_completion_state"}, + {"command":"todo +get-related-tasks","emitted":"todo-status","expect":"status","via":"override:enum-status"}, + {"command":"todo +get-related-tasks","emitted":"done","expect":"did-you-mean:blocked","via":"guard:boolean-vs-enum-status"}, + {"command":"todo +due-today","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-my-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-related-tasks","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo task list","emitted":"roles","expect":"role-types","via":"concept:todo_role_types"}, + {"command":"todo +get-my-tasks","emitted":"priorities","expect":"priority","via":"override:priority-list"}, + {"command":"todo comment add","emitted":"text","expect":"content","via":"concept:content_text"}, + {"command":"todo +comment","emitted":"body","expect":"content","via":"concept:content_text"}, + {"command":"todo comment list","emitted":"page-size","expect":"size","via":"concept:pagination_size"}, + {"command":"todo +get-my-tasks","emitted":"current-page","expect":"page","via":"concept:page_number"}, + {"command":"todo +get-my-tasks","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo +get-my-tasks","emitted":"page-size","expect":"size","via":"override:per-page-size"}, + {"command":"todo +get-my-tasks","emitted":"max-results","expect":"did-you-mean:blocked","via":"guard:total-vs-per-page"}, + {"command":"todo +list-comment","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo comment list","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo task list","emitted":"limit","expect":"size","via":"override:limit"}, + {"command":"todo task list","emitted":"page-number","expect":"page","via":"override:page-number"}, + {"command":"todo +todo-done","emitted":"query","expect":"task","via":"override:query"}, + {"command":"todo +search","emitted":"keyword","expect":"query","via":"concept:search_query"}, + {"command":"todo +search","emitted":"title","expect":"query","via":"override:title-query"}, + {"command":"todo +search","emitted":"subject","expect":"query","via":"override:subject-query"}, + {"command":"todo +reminder","emitted":"reminder-time-stamp","expect":"at","via":"override:custom-reminder-time"}, + {"command":"todo task add-reminder","emitted":"at","expect":"reminder-time-stamp","via":"override:custom-reminder-time"}, + {"command":"todo +update","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +complete","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +reopen","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +reminder","emitted":"todo-id","expect":"task-id","via":"concept:todo_task_id"}, + {"command":"todo +todo-done","emitted":"task-id","expect":"did-you-mean:blocked","via":"guard:title-query-vs-task-id"}, + {"command":"todo task list","emitted":"plan-finish-start","expect":"did-you-mean:blocked","via":"guard:iso-vs-unix-millis"}, + {"command":"todo +get-my-tasks","emitted":"plan-finish-date-start","expect":"did-you-mean:blocked","via":"guard:unix-millis-vs-iso"}, + {"command":"todo +remind","emitted":"reminder-time-stamp","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"}, + {"command":"todo +reminder","emitted":"deadline","expect":"did-you-mean:blocked","via":"guard:deadline-vs-reminder"}, + {"command":"todo task reset-reminder","emitted":"due-date-offset","expect":"did-you-mean:blocked","via":"guard:atomic-vs-json-rules"}, + {"command":"todo tag update","emitted":"tag-codes","expect":"did-you-mean:blocked","via":"guard:flat-list-vs-json"}, + {"command":"todo +search","emitted":"page-size","expect":"did-you-mean:blocked","via":"guard:max-pages-vs-page-size"}, + {"command":"todo +complete","emitted":"status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +complete","emitted":"is-done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +reopen","emitted":"done","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"todo +reopen","emitted":"todo-status","expect":"did-you-mean:blocked","via":"guard:fixed-target-state"}, + {"command":"wiki +node-copy","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +move","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +move-to-drive","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +node-delete","emitted":"node-id","expect":"node","via":"override:scoped_strict"}, + {"command":"wiki +member-add","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-add","emitted":"uid","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-update","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-update","emitted":"uid","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-remove","emitted":"user-id","expect":"user","via":"override:scoped_strict"}, + {"command":"wiki +member-remove","emitted":"uid","expect":"user","via":"override:scoped_strict"} ] } } diff --git a/internal/cli/schema_validate.go b/internal/cli/schema_validate.go index 21d7920f..90a01ead 100644 --- a/internal/cli/schema_validate.go +++ b/internal/cli/schema_validate.go @@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error { if params == nil { params = map[string]any{} } - if err := validateSchemaValue("$", params, schema); err != nil { + if err := ValidateJSONSchemaValue(params, schema); err != nil { return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err)) } return nil } +// ValidateJSONSchemaValue validates one decoded JSON value against the +// required/type/enum/properties/items subset used by reviewed CLI contracts. +func ValidateJSONSchemaValue(value any, schema map[string]any) error { + return validateSchemaValue("$", value, schema) +} + func validateSchemaValue(path string, value any, schema map[string]any) error { if len(schema) == 0 { return nil diff --git a/internal/commentreaction/validate.go b/internal/commentreaction/validate.go new file mode 100644 index 00000000..f74ab562 --- /dev/null +++ b/internal/commentreaction/validate.go @@ -0,0 +1,56 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package commentreaction validates the reviewed DingTalk names accepted by +// comment emoji replies. The names mirror the bundled default emoji catalog; +// arbitrary text and raw Unicode emoji must never be persisted as reactions. +package commentreaction + +import ( + "strings" + "unicode/utf8" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Validate rejects values outside DingTalk's reviewed default reaction-name +// catalog. Names are case-sensitive because they are sent verbatim to the +// doc-comment service. +func Validate(value string) error { + name := strings.TrimSpace(value) + if value != name || !utf8.ValidString(name) || !supported(name) { + return apperrors.NewValidation( + "--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本", + apperrors.WithReason("unsupported_comment_reaction"), + ) + } + return nil +} + +func supported(name string) bool { + switch name { + case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴", + "睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳", + "握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住", + "比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病", + "吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕", + "骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见", + "投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈", + "灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰", + "炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶", + "发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头", + "白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸", + "Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注", + "忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班", + "退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬", + "选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物", + "生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶", + "OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差", + "高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音", + "公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹", + "烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜": + return true + default: + return false + } +} diff --git a/internal/commentreaction/validate_test.go b/internal/commentreaction/validate_test.go new file mode 100644 index 00000000..abcea8a7 --- /dev/null +++ b/internal/commentreaction/validate_test.go @@ -0,0 +1,19 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package commentreaction + +import "testing" + +func TestCrossPlatformCoverageValidate(t *testing.T) { + for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} { + if err := Validate(value); err != nil { + t.Errorf("supported reaction %q rejected: %v", value, err) + } + } + for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} { + if err := Validate(value); err == nil { + t.Errorf("unsupported reaction %q accepted", value) + } + } +} diff --git a/internal/corecmd/corecmd.go b/internal/corecmd/corecmd.go index 04741dce..b94cc080 100644 --- a/internal/corecmd/corecmd.go +++ b/internal/corecmd/corecmd.go @@ -230,9 +230,10 @@ const ( // - Validate / PostMount — orchestration only; must not register business flags // or assemble business params that belong in Flags/ConstParams. // -// Exactly one of RunE / Invoke / Orchestrate must be set; New validates this at -// construction time. corecmd stays dispatch-agnostic and never calls a backend: -// the adapters (FromLeafSpec / FromShortcut) supply the body. +// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New +// validates this at construction time. corecmd stays dispatch-agnostic and +// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the +// body. type Spec struct { Use string Short string @@ -259,7 +260,8 @@ type Spec struct { // backend call). ConfirmFirst bool // ConstParams are fixed toolArgs merged after flag assembly (e.g. precheckOnly). - // They are payload declaration, not user flags, and never satisfy Required. + // They are payload declaration, not user flags, never satisfy Required, and + // require an Invoke or ResultInvoke dispatcher that consumes assembled args. ConstParams map[string]any // Contract is the authoring-time leaf contract declaration (selection / // interface / parameters / dry-run / identity). When non-empty, embed @@ -371,7 +373,9 @@ func (c *Ctx) Yes() bool { return BoolFlag(c.cmd, "yes") } // malformed spec can never run the pipeline — write-confirmation prompt // included — and then silently exit 0 having done nothing. func New(spec Spec) *cobra.Command { + spec.ConstParams = cloneConstParams(spec.ConstParams) validateDispatchDecl(spec) + validateConstParamsDecl(spec) validateSafetySpec(spec) validateContractDecl(spec) validateInputSpecs(spec.Use, spec.Flags) @@ -399,6 +403,7 @@ func New(spec Spec) *cobra.Command { if spec.PostMount != nil { spec.PostMount(cmd) } + attachInterfaceBoolConstParams(cmd, spec.ConstParams) if spec.OutputRollout != "" { output.SetCommandRollout(cmd, spec.OutputRollout) } @@ -462,6 +467,17 @@ func New(spec Spec) *cobra.Command { return cmd } +func cloneConstParams(params map[string]any) map[string]any { + if params == nil { + return nil + } + frozen := make(map[string]any, len(params)) + for key, value := range params { + frozen[key] = value + } + return frozen +} + // ConfirmFirstAnnotation marks commands whose Spec declared ConfirmFirst. The // delivery gate reads it to tell a declared guard-first command apart from an // accidental confirm-before-validate inversion: guard-first is only legitimate @@ -537,6 +553,21 @@ func validateDispatchDecl(spec Spec) { } } +func validateConstParamsDecl(spec Spec) { + if len(spec.ConstParams) == 0 { + return + } + if spec.Invoke == nil && spec.ResultInvoke == nil { + panic(fmt.Sprintf("command %q ConstParams require Invoke or ResultInvoke", spec.Use)) + } + for _, flag := range spec.Flags { + key := bindKey(flag) + if _, conflicts := spec.ConstParams[key]; conflicts { + panic(fmt.Sprintf("command %q ConstParams key %q conflicts with flag --%s", spec.Use, key, flag.Name)) + } + } +} + // validateSafetySpec rejects partial safety declarations. A zero value remains // the historical read-only default, but once any field is authored all four // independent Schema dimensions must be explicit. diff --git a/internal/corecmd/corecmd_test.go b/internal/corecmd/corecmd_test.go index a5ae5b96..f9743865 100644 --- a/internal/corecmd/corecmd_test.go +++ b/internal/corecmd/corecmd_test.go @@ -26,6 +26,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" "github.com/spf13/cobra" ) @@ -1415,17 +1416,23 @@ func TestCrossPlatformCoverageBuildArgsIntArgDefaultFloor(t *testing.T) { } } -func TestNewCommandMergesConstParams(t *testing.T) { +func TestCrossPlatformCoverageNewCommandFreezesAndMergesConstParams(t *testing.T) { var got map[string]any + declared := map[string]any{ + "precheckOnly": false, + "convThreadEnabled": true, + } cmd := New(Spec{ Use: "pub", Flags: []FlagSpec{{Name: "id", Usage: "ID", Bind: "versionId", Trim: true}}, - ConstParams: map[string]any{"precheckOnly": false}, + ConstParams: declared, Invoke: func(_ *Ctx, toolArgs map[string]any) error { got = toolArgs return nil }, }) + declared["precheckOnly"] = true + declared["forgedAfterNew"] = true _ = cmd.Flags().Set("id", "V1") if err := cmd.RunE(cmd, nil); err != nil { t.Fatal(err) @@ -1436,6 +1443,95 @@ func TestNewCommandMergesConstParams(t *testing.T) { if got["precheckOnly"] != false { t.Fatalf("precheckOnly = %#v, want false", got["precheckOnly"]) } + if got["convThreadEnabled"] != true { + t.Fatalf("convThreadEnabled = %#v, want true", got["convThreadEnabled"]) + } + if _, exists := got["forgedAfterNew"]; exists { + t.Fatalf("caller mutation leaked into dispatch args: %#v", got) + } + evidence := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(evidence, map[string]bool{"convThreadEnabled": true, "precheckOnly": false}) { + t.Fatalf("bool ConstParams evidence = %#v", evidence) + } + if got["precheckOnly"] != evidence["precheckOnly"] { + t.Fatalf("dispatch/evidence drift: args=%#v evidence=%#v", got, evidence) + } +} + +func TestCrossPlatformCoverageNewCommandDoesNotProjectMixedConstParamsEvidence(t *testing.T) { + var got map[string]any + cmd := New(Spec{ + Use: "mixed", + ConstParams: map[string]any{ + "convThreadEnabled": true, + "retryLimit": 3, + }, + Invoke: func(_ *Ctx, toolArgs map[string]any) error { + got = toolArgs + return nil + }, + }) + + if evidence := InterfaceBoolConstParams(cmd); evidence != nil { + t.Fatalf("mixed ConstParams evidence = %#v; want missing evidence", evidence) + } + if err := cmd.RunE(cmd, nil); err != nil { + t.Fatal(err) + } + if got["convThreadEnabled"] != true || got["retryLimit"] != 3 { + t.Fatalf("mixed ConstParams dispatch args = %#v", got) + } +} + +func TestCrossPlatformCoverageNewCommandRejectsInvalidConstParamsDispatch(t *testing.T) { + mustPanic := func(name string, spec Spec, needle string) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("%s: expected panic", name) + } + if msg, _ := r.(string); !strings.Contains(msg, needle) { + t.Fatalf("%s: panic=%v, want %q", name, r, needle) + } + }() + New(spec) + } + + mustPanic("RunE", Spec{ + Use: "run-e", + ConstParams: map[string]any{"fixed": true}, + RunE: func(*cobra.Command, []string) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("Orchestrate", Spec{ + Use: "orchestrate", + ConstParams: map[string]any{"fixed": true}, + Orchestrate: func(*Ctx) error { return nil }, + }, "ConstParams require Invoke or ResultInvoke") + mustPanic("explicit bind conflict", Spec{ + Use: "bind-conflict", + Flags: []FlagSpec{{Name: "thread", Usage: "T", Bind: "convThreadEnabled"}}, + ConstParams: map[string]any{"convThreadEnabled": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --thread") + mustPanic("default bind conflict", Spec{ + Use: "default-bind-conflict", + Flags: []FlagSpec{{Name: "fixed-value", Usage: "F"}}, + ConstParams: map[string]any{"fixedValue": true}, + Invoke: func(*Ctx, map[string]any) error { return nil }, + }, "conflicts with flag --fixed-value") + + result := New(Spec{ + Use: "result", + OutputRollout: output.RolloutUnifiedActive, + ConstParams: map[string]any{"fixed": true}, + ResultInvoke: func(*Ctx, map[string]any) (output.CommandResult, error) { + return output.Success(nil), nil + }, + }) + if evidence := InterfaceBoolConstParams(result); !evidence["fixed"] { + t.Fatalf("ResultInvoke ConstParams evidence = %#v", evidence) + } } func TestCrossPlatformCoverageNewCommandConfirmFirstAnnotationAndOrder(t *testing.T) { diff --git a/internal/corecmd/interface_const_params.go b/internal/corecmd/interface_const_params.go new file mode 100644 index 00000000..261e64d1 --- /dev/null +++ b/internal/corecmd/interface_const_params.go @@ -0,0 +1,63 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package corecmd + +import ( + "sync" + + "github.com/spf13/cobra" +) + +var interfaceBoolConstParamsRegistry sync.Map + +// attachInterfaceBoolConstParams records framework-owned ConstParams evidence. +// Only a non-empty, entirely boolean declaration is representable in v1; +// empty or mixed declarations remove any evidence for the command. +func attachInterfaceBoolConstParams(cmd *cobra.Command, params map[string]any) { + if cmd == nil { + return + } + if len(params) == 0 { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools := make(map[string]bool, len(params)) + for key, value := range params { + boolValue, ok := value.(bool) + if !ok { + interfaceBoolConstParamsRegistry.Delete(cmd) + return + } + bools[key] = boolValue + } + interfaceBoolConstParamsRegistry.Store(cmd, bools) +} + +// InterfaceBoolConstParams returns a clone of framework-owned boolean +// ConstParams evidence. Callers cannot mutate the private registry. +func InterfaceBoolConstParams(cmd *cobra.Command) map[string]bool { + if cmd == nil { + return nil + } + stored, ok := interfaceBoolConstParamsRegistry.Load(cmd) + if !ok { + return nil + } + params := stored.(map[string]bool) + clone := make(map[string]bool, len(params)) + for key, value := range params { + clone[key] = value + } + return clone +} diff --git a/internal/corecmd/interface_const_params_test.go b/internal/corecmd/interface_const_params_test.go new file mode 100644 index 00000000..ea01d24e --- /dev/null +++ b/internal/corecmd/interface_const_params_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package corecmd + +import ( + "reflect" + "testing" + + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageInterfaceBoolConstParamsRegistryClonesAndDeletes(t *testing.T) { + attachInterfaceBoolConstParams(nil, map[string]any{"ignored": true}) + if got := InterfaceBoolConstParams(nil); got != nil { + t.Fatalf("nil command evidence = %#v, want nil", got) + } + + cmd := &cobra.Command{Use: "send"} + declared := map[string]any{"convThreadEnabled": true, "precheckOnly": false} + attachInterfaceBoolConstParams(cmd, declared) + declared["convThreadEnabled"] = false + + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + got := InterfaceBoolConstParams(cmd) + if !reflect.DeepEqual(got, want) { + t.Fatalf("registered bool ConstParams = %#v, want %#v", got, want) + } + got["convThreadEnabled"] = false + if again := InterfaceBoolConstParams(cmd); !reflect.DeepEqual(again, want) { + t.Fatalf("reader leaked mutable registry state: %#v", again) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true, "retryLimit": 3}) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("mixed ConstParams retained evidence: %#v", got) + } + + attachInterfaceBoolConstParams(cmd, map[string]any{"convThreadEnabled": true}) + attachInterfaceBoolConstParams(cmd, nil) + if got := InterfaceBoolConstParams(cmd); got != nil { + t.Fatalf("empty ConstParams retained evidence: %#v", got) + } +} diff --git a/internal/event/personal/client_test.go b/internal/event/personal/client_test.go index e7ffe3af..9dfc12bd 100644 --- a/internal/event/personal/client_test.go +++ b/internal/event/personal/client_test.go @@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T) {"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}}, {"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}}, + {"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}}, {"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}}, {"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}}, diff --git a/internal/event/personal/output.go b/internal/event/personal/output.go index 00d81a01..e42d1b45 100644 --- a/internal/event/personal/output.go +++ b/internal/event/personal/output.go @@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct { EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"` } +type OAApprovalInstanceCCOutput struct { + Type string `json:"type" description:"事件类型,固定为当前 event_key"` + EventID string `json:"event_id" description:"事件 ID,可用于去重"` + Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"` + SubscribeID string `json:"subscribe_id" description:"订阅 ID"` + ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"` + ProcessCode string `json:"process_code" description:"审批流程模板编码"` + Title string `json:"title" description:"审批标题"` + Status string `json:"status" description:"审批实例到达抄送节点时的状态"` + CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"` + EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"` +} + type OAApprovalInstanceTerminatedOutput struct { Type string `json:"type" description:"事件类型,固定为当前 event_key"` EventID string `json:"event_id" description:"事件 ID,可用于去重"` @@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R CreateTime: payload.Body.CreateTime, EventTime: payload.EventTime, }, nil + case EventOAApprovalInstanceCC: + return OAApprovalInstanceCCOutput{ + Type: base.Type, + EventID: base.EventID, + Timestamp: base.Timestamp, + SubscribeID: base.SubscribeID, + ProcessInstanceID: payload.Body.ProcessInstanceID, + ProcessCode: payload.Body.ProcessCode, + Title: payload.Body.Title, + Status: payload.Body.Status, + CreateTime: payload.Body.CreateTime, + EventTime: payload.EventTime, + }, nil case EventOAApprovalInstanceTerminated: return OAApprovalInstanceTerminatedOutput{ Type: base.Type, @@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type { return reflect.TypeOf(OAApprovalTaskRedirectedOutput{}) case eventKey == EventOAApprovalInstanceStarted: return reflect.TypeOf(OAApprovalInstanceStartedOutput{}) + case eventKey == EventOAApprovalInstanceCC: + return reflect.TypeOf(OAApprovalInstanceCCOutput{}) case eventKey == EventOAApprovalInstanceTerminated: return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{}) case eventKey == EventOAApprovalInstanceFinished: @@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool { eventKey == EventOAApprovalTaskFinished || eventKey == EventOAApprovalTaskRedirected || eventKey == EventOAApprovalInstanceStarted || + eventKey == EventOAApprovalInstanceCC || eventKey == EventOAApprovalInstanceTerminated || eventKey == EventOAApprovalInstanceFinished } diff --git a/internal/event/personal/output_test.go b/internal/event/personal/output_test.go index 326caf67..e9de2e00 100644 --- a/internal/event/personal/output_test.go +++ b/internal/event/personal/output_test.go @@ -173,6 +173,8 @@ func personalOAData(eventKey string) string { body["finishTime"] = int64(1785229199000) case EventOAApprovalInstanceStarted: body["status"] = "RUNNING" + case EventOAApprovalInstanceCC: + body["status"] = "RUNNING" case EventOAApprovalInstanceTerminated: body["status"] = "TERMINATED" body["finishTime"] = int64(1785229199000) @@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) { EventTime: 1785229199000, }, }, + { + eventKey: EventOAApprovalInstanceCC, + want: OAApprovalInstanceCCOutput{ + Type: EventOAApprovalInstanceCC, + EventID: "oa-event", + Timestamp: 1785229200123, + SubscribeID: "outer-sub", + ProcessInstanceID: "process-instance-1", + ProcessCode: "PROC-TEST-1", + Title: "测试审批", + Status: "RUNNING", + CreateTime: 1785229100000, + EventTime: 1785229199000, + }, + }, { eventKey: EventOAApprovalInstanceTerminated, want: OAApprovalInstanceTerminatedOutput{ @@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { diff --git a/internal/event/personal/registry.go b/internal/event/personal/registry.go index bffb7918..50e3ce03 100644 --- a/internal/event/personal/registry.go +++ b/internal/event/personal/registry.go @@ -43,6 +43,7 @@ const ( EventOAApprovalTaskFinished = "user_oa_approval_task_finished" EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected" EventOAApprovalInstanceStarted = "user_oa_approval_instance_started" + EventOAApprovalInstanceCC = "user_oa_approval_instance_cc" EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated" EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished" ) @@ -323,6 +324,17 @@ var definitions = []Definition{ Auth: map[string]any{"identity": "user"}, Public: true, }, + { + EventKey: EventOAApprovalInstanceCC, + DisplayName: "审批单抄送", + Description: "审批实例到达抄送节点,发送给被抄送人", + Category: "oa", + RuleType: "all", + Status: StatusEnabled, + RequiredParams: nil, + Auth: map[string]any{"identity": "user"}, + Public: true, + }, { EventKey: EventOAApprovalInstanceTerminated, DisplayName: "审批单终止", diff --git a/internal/event/personal/registry_test.go b/internal/event/personal/registry_test.go index fb01a3b3..1b80fa82 100644 --- a/internal/event/personal/registry_test.go +++ b/internal/event/personal/registry_test.go @@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } @@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } @@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { @@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) { "process_code", "title", "status", "create_time", "event_time", }, }, + { + eventKey: EventOAApprovalInstanceCC, + properties: []string{ + "type", "event_id", "timestamp", "subscribe_id", "process_instance_id", + "process_code", "title", "status", "create_time", "event_time", + }, + }, { eventKey: EventOAApprovalInstanceTerminated, properties: []string{ @@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, } { @@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) { EventOAApprovalTaskFinished, EventOAApprovalTaskRedirected, EventOAApprovalInstanceStarted, + EventOAApprovalInstanceCC, EventOAApprovalInstanceTerminated, EventOAApprovalInstanceFinished, "unknown_event", diff --git a/internal/helpers/aitable.go b/internal/helpers/aitable.go index f981d7cd..2f72dcd0 100644 --- a/internal/helpers/aitable.go +++ b/internal/helpers/aitable.go @@ -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{ diff --git a/internal/helpers/aitable_command_edges_test.go b/internal/helpers/aitable_command_edges_test.go index e821023c..738059ea 100644 --- a/internal/helpers/aitable_command_edges_test.go +++ b/internal/helpers/aitable_command_edges_test.go @@ -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") + } +} diff --git a/internal/helpers/aitable_core_test.go b/internal/helpers/aitable_core_test.go index 66f97454..774008b8 100644 --- a/internal/helpers/aitable_core_test.go +++ b/internal/helpers/aitable_core_test.go @@ -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 "" } diff --git a/internal/helpers/aitable_workflow_write_test.go b/internal/helpers/aitable_workflow_write_test.go index 1feff897..3709277c 100644 --- a/internal/helpers/aitable_workflow_write_test.go +++ b/internal/helpers/aitable_workflow_write_test.go @@ -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 { diff --git a/internal/helpers/chat.go b/internal/helpers/chat.go index af6b16b1..94720e8d 100644 --- a/internal/helpers/chat.go +++ b/internal/helpers/chat.go @@ -27,6 +27,38 @@ import ( "github.com/spf13/cobra" ) +func resolveChatGroupRoleSetUserRoleIDs(cmd *cobra.Command) ([]string, error) { + roleIDChanged := cmd.Flags().Changed("role-id") + roleIDsChanged := cmd.Flags().Changed("role-ids") + switch { + case roleIDChanged && roleIDsChanged: + // PreRunE rejects callers that explicitly pass both flags. Reaching this + // branch means the hidden legacy flag was promoted to satisfy Cobra's + // required marker on the public canonical flag. + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + case roleIDChanged: + roleIDs := parseCSVValues(mustGetFlag(cmd, "role-id")) + if len(roleIDs) == 0 { + return nil, apperrors.NewValidation("--role-id 不能为空") + } + if len(roleIDs) > 1 { + return nil, apperrors.NewValidation("--role-id 只允许指定一个群身份") + } + return roleIDs, nil + case roleIDsChanged: + return parseCSVValues(mustGetFlag(cmd, "role-ids")), nil + default: + return nil, apperrors.NewValidation("缺少必填参数 --role-id") + } +} + +func prepareChatGroupRoleSetUserRoleID(cmd *cobra.Command) error { + if cmd.Flags().Changed("role-id") && cmd.Flags().Changed("role-ids") { + return apperrors.NewValidation("--role-id 与 --role-ids 不能同时指定") + } + return promoteLegacyChatString(cmd, "role-id", "role-ids") +} + // promoteLegacyChatString copies an explicitly supplied legacy flag into the // new canonical flag. Cobra validates MarkFlagRequired after PreRunE, and the // overwrite also preserves the migration rule that a legacy value wins when @@ -7753,17 +7785,23 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd := &cobra.Command{ Use: "set-user", Short: "设置用户的群身份(覆盖该用户的全部群身份)", - Example: ` dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2 + Example: ` dws chat group-role set-user --conversation-id --user --role-id # 查询人员: dws contact user search --keyword "姓名" --format json # 查询 role-id: dws chat group-role list --conversation-id `, + PreRunE: func(cmd *cobra.Command, args []string) error { + return prepareChatGroupRoleSetUserRoleID(cmd) + }, RunE: func(cmd *cobra.Command, args []string) error { - if err := validateRequiredFlags(cmd, "conversation-id", "role-ids"); err != nil { + if err := validateRequiredFlags(cmd, "conversation-id"); err != nil { return err } if err := validateRequiredFlagWithAliases(cmd, "user", "userId"); err != nil { return err } - roleIDs := parseCSVValues(mustGetFlag(cmd, "role-ids")) + roleIDs, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + return err + } user := flagOrFallback(cmd, "user", "userId") toolArgs := map[string]any{ "openConversationId": flagOrFallback(cmd, "conversation-id", "group", "id", "chat"), @@ -7798,13 +7836,13 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 }, Selection: contract.SelectionSpec{ AgentSummary: "为指定群成员设置自定义角色", - UseWhen: []string{"需要把一个或多个已有角色分配给成员时"}, + UseWhen: []string{"需要把一个已有角色分配给成员时"}, AvoidWhen: []string{"创建新角色定义时使用 chat group-role add"}, - Examples: []string{"dws chat group-role set-user --conversation-id --user --role-ids roleId1,roleId2"}, + Examples: []string{"dws chat group-role set-user --conversation-id --user --role-id "}, }, Parameters: []contract.ParamDecl{ {Name: "conversation-id", Property: "openConversationId"}, - {Name: "role-ids", Property: "openRoleIds"}, + {Name: "role-id", Property: "openRoleIds"}, {Name: "user", Property: "openDingTalkId"}, }, }, @@ -7814,8 +7852,11 @@ flow-status 取值:1=处理中(PROCESSING),2=输入中(INPUTTING),3=完成 chatGroupRoleSetUserCmd.Flags().String("user", "", "用户 userId(必填)") chatGroupRoleSetUserCmd.Flags().String("userId", "", "--user 的别名") _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("userId") - chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份") - _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-ids") + chatGroupRoleSetUserCmd.Flags().String("role-id", "", "群身份 openRoleId,由 group-role list 返回 (必填)") + chatGroupRoleSetUserCmd.Flags().String("role-ids", "", "已隐藏的兼容参数:群身份 openRoleId 列表,逗号分隔") + _ = chatGroupRoleSetUserCmd.Flags().MarkHidden("role-ids") + corecmd.AnnotateFlagAlias(chatGroupRoleSetUserCmd, "role-ids", "role-id") + _ = chatGroupRoleSetUserCmd.MarkFlagRequired("role-id") chatGroupRoleRemoveUserCmd := &cobra.Command{ Use: "remove-user", @@ -10528,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 diff --git a/internal/helpers/chat_command_edges_test.go b/internal/helpers/chat_command_edges_test.go index 118cc983..b2f70a1b 100644 --- a/internal/helpers/chat_command_edges_test.go +++ b/internal/helpers/chat_command_edges_test.go @@ -243,6 +243,155 @@ func TestCrossPlatformCoverageChatGroupUpdateIconRejectsBlankMediaID(t *testing. } } +func TestChatGroupRoleSetUserAcceptsSingleRoleIDAndLegacyRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want []string + }{ + { + name: "public single role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, + want: []string{"r1"}, + }, + { + name: "hidden legacy role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1,r2"}, + want: []string{"r1", "r2"}, + }, + { + name: "hidden legacy empty role ids", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids="}, + want: nil, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + if err := runChatCoverageCommand(t, caller, tc.args...); err != nil { + t.Fatal(err) + } + if caller.calls != 1 { + t.Fatalf("tool calls = %d, want 1", caller.calls) + } + if got := caller.args["openRoleIds"]; !reflect.DeepEqual(got, tc.want) { + t.Fatalf("openRoleIds = %#v, want %#v", got, tc.want) + } + }) + } +} + +func TestChatGroupRoleSetUserRejectsConflictingRoleFlags(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1", "--role-ids=r2") + if err == nil { + t.Fatal("set-user accepted --role-id with --role-ids, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 与 --role-ids 不能同时指定") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMultiplePublicRoleIDs(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, + "group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1,r2") + if err == nil { + t.Fatal("set-user accepted multiple --role-id values, want validation error") + } + if !strings.Contains(err.Error(), "--role-id 只允许指定一个群身份") { + t.Fatalf("error = %v", err) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } +} + +func TestChatGroupRoleSetUserRejectsMissingOrEmptyPublicRoleID(t *testing.T) { + previousDeps, previousArgs := deps, os.Args + os.Args = []string{"dws", "chat"} + t.Cleanup(func() { deps, os.Args = previousDeps, previousArgs }) + + tests := []struct { + name string + args []string + want string + }{ + { + name: "missing public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1"}, + want: "role-id", + }, + { + name: "empty public role id", + args: []string{"group-role", "set-user", "--group=cid", "--user=D1", "--role-id="}, + want: "--role-id 不能为空", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &scriptedToolCaller{} + err := runChatCoverageCommand(t, caller, tc.args...) + if err == nil { + t.Fatal("set-user accepted a missing or empty --role-id, want validation error") + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v, want substring %q", err, tc.want) + } + if caller.calls != 0 { + t.Fatalf("tool calls = %d, want 0", caller.calls) + } + }) + } +} + +func TestChatGroupRoleSetUserRoleIDResolverDefensiveBranches(t *testing.T) { + newCommand := func(t *testing.T) *cobra.Command { + t.Helper() + cmd := &cobra.Command{} + cmd.Flags().String("role-id", "", "") + cmd.Flags().String("role-ids", "", "") + return cmd + } + + t.Run("legacy flag without pre-run promotion", func(t *testing.T) { + cmd := newCommand(t) + if err := cmd.Flags().Set("role-ids", "r1,r2"); err != nil { + t.Fatal(err) + } + got, err := resolveChatGroupRoleSetUserRoleIDs(cmd) + if err != nil { + t.Fatal(err) + } + if want := []string{"r1", "r2"}; !reflect.DeepEqual(got, want) { + t.Fatalf("role IDs = %#v, want %#v", got, want) + } + }) + + t.Run("no role flag", func(t *testing.T) { + _, err := resolveChatGroupRoleSetUserRoleIDs(newCommand(t)) + if err == nil || !strings.Contains(err.Error(), "缺少必填参数 --role-id") { + t.Fatalf("error = %v, want missing --role-id validation", err) + } + }) +} + func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) { previousDeps, previousArgs := deps, os.Args os.Args = []string{"dws", "chat"} @@ -293,7 +442,7 @@ func TestCrossPlatformCoverageChatCommandValidationAndSuccessEdges(t *testing.T) {"group", "audit-join-validation", "--conversation-id=cid", "--record-id=1", "--applicant=D1", "--inviter=D2", "--status=AuditApprove", "--description=ok"}, {"mark-read", "--conversation-id=cid", "--message-id=mid"}, {"text", "translate", "--query=hello", "--to=zh_CN"}, - {"group-role", "set-user", "--group=cid", "--user=D1", "--role-ids=r1"}, + {"group-role", "set-user", "--group=cid", "--user=D1", "--role-id=r1"}, {"group-role", "remove-user", "--group=cid", "--user=D1", "--role-ids=r1"}, {"group-role", "query-user", "--group=cid", "--user=D1"}, {"group", "set-admin", "--group=cid", "--users=u1,D1"}, @@ -523,7 +672,7 @@ func TestCrossPlatformCoverageChatIMIDMigrationRequiredFlagErrors(t *testing.T) {name: "role add missing conversation", path: []string{"group-role", "add"}, flag: map[string]string{"name": "role"}, want: "conversation-id"}, {name: "role update missing conversation", path: []string{"group-role", "update"}, flag: map[string]string{"role-id": "r1", "name": "role"}, want: "conversation-id"}, {name: "role remove missing conversation", path: []string{"group-role", "remove"}, flag: map[string]string{"role-id": "r1"}, want: "conversation-id"}, - {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, + {name: "role set user missing conversation", path: []string{"group-role", "set-user"}, flag: map[string]string{"user": "D1", "role-id": "r1"}, want: "conversation-id"}, {name: "role remove user missing conversation", path: []string{"group-role", "remove-user"}, flag: map[string]string{"user": "D1", "role-ids": "r1"}, want: "conversation-id"}, {name: "role query user missing conversation", path: []string{"group-role", "query-user"}, flag: map[string]string{"user": "D1"}, want: "conversation-id"}, {name: "bots missing legacy group", path: []string{"group", "bots"}, want: "group"}, diff --git a/internal/helpers/chat_personal_emotion.go b/internal/helpers/chat_personal_emotion.go new file mode 100644 index 00000000..eb46fe67 --- /dev/null +++ b/internal/helpers/chat_personal_emotion.go @@ -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 --group + dws chat emotion send --media-id --emotion-id --user + dws chat emotion send --media-id --open-dingtalk-id --uuid `, + 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 --name "赞" + dws chat emotion favorite --media-id --source-conversation-id --source-message-id `, + 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 --group --uuid "}, + }, + 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 --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"}, + }, + } +} diff --git a/internal/helpers/chat_personal_emotion_test.go b/internal/helpers/chat_personal_emotion_test.go new file mode 100644 index 00000000..860cbc66 --- /dev/null +++ b/internal/helpers/chat_personal_emotion_test.go @@ -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) + } + }) + } +} diff --git a/internal/helpers/comment_base.go b/internal/helpers/comment_base.go index 81f0c8e3..4af1c8ca 100644 --- a/internal/helpers/comment_base.go +++ b/internal/helpers/comment_base.go @@ -18,6 +18,7 @@ import ( "fmt" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -231,6 +232,9 @@ Unicode Emoji。例如用户要求 😄 时传“憨笑”,要求 👏 时传 if err := validateRequiredFlags(cmd, "comment-key", "reaction"); err != nil { return err } + if err := commentreaction.Validate(mustGetFlag(cmd, "reaction")); err != nil { + return err + } return callMCPToolOnServer(commentServer, "reply_comment", map[string]any{ "nodeId": nodeID, "replyCommentKey": mustGetFlag(cmd, "comment-key"), diff --git a/internal/helpers/comment_base_test.go b/internal/helpers/comment_base_test.go index 2d6e8638..423c0797 100644 --- a/internal/helpers/comment_base_test.go +++ b/internal/helpers/comment_base_test.go @@ -135,6 +135,29 @@ func TestCrossPlatformCoverageCommentReactReplyForcesEmojiTrue(t *testing.T) { } } +func TestCrossPlatformCoverageCommentReactionsRejectUnsupportedValuesBeforeRPC(t *testing.T) { + for _, surface := range []string{"doc", "sheet"} { + for _, test := range []struct { + name string + args []string + }{ + {name: "react unicode", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "😄"}}, + {name: "react garbage", args: []string{"comment", "react-reply", "--node", "node-1", "--comment-key", "comment-1", "--reaction", "乱码"}}, + {name: "reply unicode", args: []string{"comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "👏", "--emoji"}}, + } { + t.Run(surface+"/"+test.name, func(t *testing.T) { + caller := &docCommentMutationCaller{} + if err := executeCommentBaseCommand(t, caller, surface, test.args...); err == nil { + t.Fatal("unsupported reaction accepted") + } + if len(caller.calls) != 0 { + t.Fatalf("unsupported reaction reached RPC: %#v", caller.calls) + } + }) + } + } +} + func TestCrossPlatformCoverageCommentReactReplyGuidesDingTalkEmojiNames(t *testing.T) { for _, surface := range []struct { name string diff --git a/internal/helpers/ding.go b/internal/helpers/ding.go index 79cda622..c3db7200 100644 --- a/internal/helpers/ding.go +++ b/internal/helpers/ding.go @@ -17,6 +17,16 @@ import ( // remindType: 服务端 API 1=应用内 2=短信 3=电话 var dingRemindTypeMap = map[string]int{"app": 1, "sms": 2, "call": 3} +var dingPersonalRemindTypeMap = map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE"} + +func dingPersonalRemindType(value string) (string, error) { + remindType, ok := dingPersonalRemindTypeMap[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return "", fmt.Errorf("--type must be one of app, sms, call") + } + return remindType, nil +} + func newDingCommand() *cobra.Command { // Product-level Agent routing Decl (migrated from selection/ding.json // products.ding). Catalog assembly stamps provenance contract_final. @@ -232,11 +242,15 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "users", "content"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "receiverOpenDingTalkIds": users, "content": mustGetFlag(cmd, "content"), - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v @@ -262,12 +276,16 @@ func newDingCommand() *cobra.Command { if err := validateRequiredFlags(cmd, "group", "message-id", "users"); err != nil { return err } + remindType, err := dingPersonalRemindType(mustGetFlag(cmd, "type")) + if err != nil { + return err + } users := parseCSVValues(mustGetFlag(cmd, "users")) toolArgs := map[string]any{ "openConversationId": mustGetFlag(cmd, "group"), "openMessageId": mustGetFlag(cmd, "message-id"), "receiverOpenDingTalkIds": users, - "remindType": mustGetFlag(cmd, "type"), + "remindType": remindType, } if v, _ := cmd.Flags().GetString("uuid"); v != "" { toolArgs["uuid"] = v diff --git a/internal/helpers/ding_personal_remind_type_test.go b/internal/helpers/ding_personal_remind_type_test.go new file mode 100644 index 00000000..b94a323d --- /dev/null +++ b/internal/helpers/ding_personal_remind_type_test.go @@ -0,0 +1,18 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package helpers + +import "testing" + +func TestCrossPlatformCoverageDingPersonalRemindTypeMatchesMCPEnum(t *testing.T) { + for input, want := range map[string]string{"app": "APP", "sms": "SMS", "call": "PHONE", " APP ": "APP"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want %q", input, got, err, want) + } + } + if got, err := dingPersonalRemindType("push"); err == nil || got != "" { + t.Fatalf("unsupported remind type=(%q,%v)", got, err) + } +} diff --git a/internal/helpers/doc.go b/internal/helpers/doc.go index 177f2976..23cbb186 100644 --- a/internal/helpers/doc.go +++ b/internal/helpers/doc.go @@ -15,6 +15,7 @@ import ( "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -3220,6 +3221,9 @@ commentKey可从 dws doc comment create 或 dws doc comment list 返回结果中 "replyCommentKey": mustGetFlag(cmd, "comment-key"), } if v, _ := cmd.Flags().GetBool("emoji"); v { + if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil { + return err + } groupMentions, err := commentGroupMentionIDs(cmd) if err != nil { return err diff --git a/internal/helpers/doc_group_mentions_test.go b/internal/helpers/doc_group_mentions_test.go index cebdcb80..ea17f12e 100644 --- a/internal/helpers/doc_group_mentions_test.go +++ b/internal/helpers/doc_group_mentions_test.go @@ -219,7 +219,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", "--mentioned-open-conversation-id", "oc-1", ) if err == nil || !strings.Contains(err.Error(), "emoji replies do not support group mentions") { @@ -235,7 +235,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", "--mentioned-open-conversation-id", " ", ) if err == nil || !strings.Contains(err.Error(), "must not be empty") { @@ -251,7 +251,7 @@ func TestCrossPlatformCoverageDocCommentGroupMentionValidationAndCompatibility(t err := executeDocGroupMentionCommand( t, caller, - "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "like", + "comment", "reply", "--node", "doc-1", "--comment-key", "comment-1", "--content", "赞", "--emoji", ) if err != nil { diff --git a/internal/helpers/drive_folder_contract.go b/internal/helpers/drive_folder_contract.go index 624db528..57d90d72 100644 --- a/internal/helpers/drive_folder_contract.go +++ b/internal/helpers/drive_folder_contract.go @@ -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"]}, diff --git a/internal/helpers/drive_folder_contract_test.go b/internal/helpers/drive_folder_contract_test.go index 6495708d..3898cb81 100644 --- a/internal/helpers/drive_folder_contract_test.go +++ b/internal/helpers/drive_folder_contract_test.go @@ -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 { diff --git a/internal/helpers/drive_push.go b/internal/helpers/drive_push.go index e5e64e25..31431440 100644 --- a/internal/helpers/drive_push.go +++ b/internal/helpers/drive_push.go @@ -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) } diff --git a/internal/helpers/drive_push_sync_final_coverage_test.go b/internal/helpers/drive_push_sync_final_coverage_test.go index 715e4362..199b4261 100644 --- a/internal/helpers/drive_push_sync_final_coverage_test.go +++ b/internal/helpers/drive_push_sync_final_coverage_test.go @@ -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) diff --git a/internal/helpers/drive_sync.go b/internal/helpers/drive_sync.go index dad77166..b7dbad34 100644 --- a/internal/helpers/drive_sync.go +++ b/internal/helpers/drive_sync.go @@ -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 { diff --git a/internal/helpers/drive_sync_confirmation_test.go b/internal/helpers/drive_sync_confirmation_test.go index 8c22e83b..0c8974d0 100644 --- a/internal/helpers/drive_sync_confirmation_test.go +++ b/internal/helpers/drive_sync_confirmation_test.go @@ -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()) } } diff --git a/internal/helpers/framework2_changed_lines_coverage_test.go b/internal/helpers/framework2_changed_lines_coverage_test.go index 00053300..a80a0751 100644 --- a/internal/helpers/framework2_changed_lines_coverage_test.go +++ b/internal/helpers/framework2_changed_lines_coverage_test.go @@ -58,6 +58,12 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { t.Fatalf("decoded data=%#v err=%v", data, err) } + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"count":7}`}}}}) + data, err = CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil) + if err != nil || data.(map[string]any)["count"] != float64(7) { + t.Fatalf("legacy float64 number data=%#v err=%v", data, err) + } + InitDeps(&coverageErrorCaller{err: errors.New("transport failed")}) if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected transport error") @@ -73,6 +79,16 @@ func TestCrossPlatformCoverageFramework2MCPDataEdges(t *testing.T) { if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil { t.Fatal("expected invalid JSON error") } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {}`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "存在多个 JSON 值") { + t.Fatalf("multiple JSON values error = %v", err) + } + + InitDeps(&coverageErrorCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{} {`}}}}) + if _, err := CallMCPToolDataOnServer(context.Background(), "dev", "get_thing", nil); err == nil || !strings.Contains(err.Error(), "解析 get_thing 返回失败") { + t.Fatalf("trailing invalid JSON error = %v", err) + } } func TestCrossPlatformCoverageFramework2LegacyTextAdapter(t *testing.T) { diff --git a/internal/helpers/helpers.go b/internal/helpers/helpers.go index be92c38e..f2607027 100644 --- a/internal/helpers/helpers.go +++ b/internal/helpers/helpers.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "io" "os" "strings" "time" @@ -305,7 +306,14 @@ func CallMCPToolDataOnServer(ctx context.Context, serverID, toolName string, arg return map[string]any{}, nil } var data any - if err := json.Unmarshal([]byte(text), &data); err != nil { + decoder := json.NewDecoder(strings.NewReader(text)) + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", toolName, err)) } return data, nil diff --git a/internal/helpers/oa.go b/internal/helpers/oa.go index 8dfe47b5..c35c41cc 100644 --- a/internal/helpers/oa.go +++ b/internal/helpers/oa.go @@ -2,12 +2,16 @@ package helpers import ( "bytes" + "context" "encoding/json" "errors" "fmt" "io" + "os" + "path/filepath" "strconv" "strings" + "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -17,6 +21,9 @@ import ( "github.com/spf13/cobra" ) +// computeFileMD5 is the function used to calculate file MD5. Tests can override via direct assignment. +var computeFileMD5 = fileMD5Hex + func decodeOARequest(raw string) (map[string]any, error) { dec := json.NewDecoder(bytes.NewBufferString(raw)) dec.UseNumber() @@ -125,7 +132,13 @@ func validateOAPreviewFileIDs(cmd *cobra.Command, _ []string) error { } func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { - data, err := CallMCPToolDataOnServer(cmd.Context(), "oa", tool, args) + return callOAAttachmentResultCtx(cmd.Context(), tool, args) +} + +// callOAAttachmentResultCtx 复用统一结果投影逻辑,但允许调用方传入自定义 context +// (例如 upload 命令的 10 分钟超时):调用 oa/,提取 result 并包装为 output.Success。 +func callOAAttachmentResultCtx(ctx context.Context, tool string, args map[string]any) (output.CommandResult, error) { + data, err := CallMCPToolDataOnServer(ctx, "oa", tool, args) if err != nil { return nil, err } @@ -140,10 +153,225 @@ func callOAAttachmentResult(cmd *cobra.Command, tool string, args map[string]any return output.Success(result), nil } +// validateOAAttachmentCommitResult 校验 commit_attachment_upload_info 的原始 result +// 包含构造 DDAttachment 所需的全部必需字段,并将 spaceId/fileSize 归一化为声明的 integer +// 类型(int64),确保输出始终符合 ResultSpec schema。当字段缺失或类型错误时返回 Validation +// 错误,避免 Agent 拿到空 fileId 后组装无效的审批表单。 +func validateOAAttachmentCommitResult(result any) (map[string]any, error) { + resultMap, ok := result.(map[string]any) + if !ok || resultMap == nil { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值 result 不是有效的 JSON 对象") + } + + // spaceId — 接受 string 或 number,归一化为 int64 + switch v := resultMap["spaceId"].(type) { + case string: + if strings.TrimSpace(v) == "" { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + } + parsed, err := strconv.ParseInt(strings.TrimSpace(v), 10, 64) + if err != nil { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数") + } + resultMap["spaceId"] = parsed + case float64: + // numeric spaceId — keep as-is (already matches JSON number) + case json.Number: + parsed, err := v.Int64() + if err != nil { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 spaceId 不是有效整数") + } + resultMap["spaceId"] = parsed + default: + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 spaceId") + } + + // fileName — must be non-empty string + if s, ok := resultMap["fileName"].(string); !ok || strings.TrimSpace(s) == "" { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileName") + } + + // fileSize — must be > 0 (number),归一化 json.Number → int64 + switch v := resultMap["fileSize"].(type) { + case float64: + if v <= 0 { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + } + case json.Number: + parsed, err := v.Int64() + if err != nil || parsed <= 0 { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值字段 fileSize 必须大于 0") + } + resultMap["fileSize"] = parsed + default: + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileSize") + } + + // fileId — must be non-empty string + if s, ok := resultMap["fileId"].(string); !ok || strings.TrimSpace(s) == "" { + return nil, apperrors.NewValidation("oa/commit_attachment_upload_info 返回值缺少必需字段 fileId") + } + + return resultMap, nil +} + +// parseOAAttachmentUploadInfo 解析 oa/init_attachment_upload_info 的返回,提取首个上传地址、 +// 签名请求头与 uploadKey。OA 的返回结构为 result.resourceUrls([]string)、result.headers(object)、 +// result.uploadKey(string),与钉盘 doc 的 resourceUrl(单数)不同,因此单独实现。 +func parseOAAttachmentUploadInfo(text string) (resourceURL string, headers map[string]string, uploadKey string, err error) { + var data map[string]any + if err = json.Unmarshal([]byte(text), &data); err != nil { + return "", nil, "", apperrors.NewInternal(fmt.Sprintf("解析 init_attachment_upload_info 返回失败: %v", err)) + } + result, ok := data["result"].(map[string]any) + if !ok { + return "", nil, "", apperrors.NewInternal("oa/init_attachment_upload_info 返回值缺少 result") + } + + uploadKey, _ = result["uploadKey"].(string) + if strings.TrimSpace(uploadKey) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 uploadKey") + } + + rawURLs, ok := result["resourceUrls"].([]any) + if !ok || len(rawURLs) == 0 { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 resourceUrls") + } + resourceURL, _ = rawURLs[0].(string) + if strings.TrimSpace(resourceURL) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 首个 resourceUrls 为空") + } + + rawHeaders, ok := result["headers"].(map[string]any) + if !ok { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info 返回值缺少 headers") + } + headers = make(map[string]string) + for key, value := range rawHeaders { + if str, ok := value.(string); ok { + headers[key] = str + } + } + if strings.TrimSpace(headers["Authorization"]) == "" || strings.TrimSpace(headers["x-oss-date"]) == "" { + return "", nil, "", apperrors.NewValidation("oa/init_attachment_upload_info headers 缺少必需的签名字段 Authorization 或 x-oss-date") + } + return resourceURL, headers, uploadKey, nil +} + +// runOAAttachmentUpload 端到端上传审批附件:init 获取 OSS 上传凭证 → HTTP PUT 上传文件字节 +// → commit 提交入库,一条命令完成三步。PUT 复用包级 httpPutFile(它会删除 Content-Type 并 +// 写入签名请求头,避免钉钉 OSS SignatureDoesNotMatch),不要在此重复实现。 +func runOAAttachmentUpload(cmd *cobra.Command, _ []string) error { + filePath := strings.TrimSpace(mustGetFlag(cmd, "file")) + if filePath == "" { + return apperrors.NewValidation("--file 不能为空") + } + info, err := os.Stat(filePath) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("无法读取文件 %s: %v", filePath, err)) + } + if info.IsDir() { + return apperrors.NewValidation(fmt.Sprintf("%s 是目录,不是文件", filePath)) + } + fileSize := info.Size() + + fileName := strings.TrimSpace(mustGetFlag(cmd, "file-name")) + if fileName == "" { + fileName = filepath.Base(filePath) + } + + md5Hex := strings.TrimSpace(mustGetFlag(cmd, "md5")) + if md5Hex == "" { + md5Hex, err = computeFileMD5(filePath) + if err != nil { + return apperrors.NewInternal(fmt.Sprintf("计算文件 MD5 失败: %v", err)) + } + } + + // --dry-run:本地只读工作(校验、Stat、大小、文件名、MD5)已完成, + // 在任何远程调用(init/PUT/commit)之前 early return,输出 plan 预览。 + // 本命令是 RolloutUnifiedActive,必须经 output.StoreResult 存入统一结果, + // 不能直接 PrintJSON(否则框架报 "returned without a CommandResult")。 + // 不伪造 uploadKey/resourceURL/fileId/spaceId——它们只能由远程调用返回。 + if deps.Caller.DryRun() { + return output.StoreResult(cmd.Context(), output.Success(map[string]any{ + "dry_run": true, + "executed": false, + "preview_kind": "plan", + "operation": "attachment_upload", + "source": "oa", + "file": filePath, + "file_name": fileName, + "file_size": fileSize, + "md5": md5Hex, + "steps": []map[string]any{ + { + "tool": "oa/init_attachment_upload_info", + "args": map[string]any{"fileName": fileName, "fileSize": fileSize, "md5": md5Hex}, + "status": "planned", + }, + { + "tool": "HTTP PUT", + "args": map[string]any{"file": filePath, "fileSize": fileSize}, + "status": "planned", + }, + { + "tool": "oa/commit_attachment_upload_info", + "args": map[string]any{"fileName": fileName, "fileSize": fileSize}, + "requires": []string{"uploadKey from oa/init_attachment_upload_info"}, + "status": "planned", + }, + }, + }, output.WithDryRun())) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute) + defer cancel() + + // Step 1: 初始化上传,拿到 OSS 上传地址、签名头与 uploadKey。 + initText, err := callMCPToolReturnTextOnServer(ctx, "oa", "init_attachment_upload_info", map[string]any{ + "fileName": fileName, + "fileSize": float64(fileSize), + "md5": md5Hex, + }) + if err != nil { + return err + } + resourceURL, headers, uploadKey, err := parseOAAttachmentUploadInfo(initText) + if err != nil { + return err + } + + // Step 2: HTTP PUT 文件字节到 OSS(复用 httpPutFile,勿重复实现)。 + if err := httpPutFile(ctx, resourceURL, headers, filePath, fileSize); err != nil { + return err + } + + // Step 3: 提交上传信息完成入库,校验必需字段后以统一输出渲染 commit 结果。 + commitData, err := CallMCPToolDataOnServer(ctx, "oa", "commit_attachment_upload_info", map[string]any{ + "fileName": fileName, + "uploadKey": uploadKey, + "fileSize": float64(fileSize), + }) + if err != nil { + return err + } + commitResp, ok := commitData.(map[string]any) + if !ok { + return apperrors.NewInternal("oa/commit_attachment_upload_info 返回值不是 JSON 对象") + } + commitResult, _ := commitResp["result"] + normalized, err := validateOAAttachmentCommitResult(commitResult) + if err != nil { + return err + } + return output.StoreResult(cmd.Context(), output.Success(normalized)) +} + func newOAAttachmentCommand() *cobra.Command { attachmentCmd := &cobra.Command{ Use: "attachment", - Short: "审批附件授权与下载链接", + Short: "审批附件授权、上传、下载与链接管理", RunE: groupRunE, } @@ -318,10 +546,156 @@ func newOAAttachmentCommand() *cobra.Command { }, }) - attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd) + uploadCmd := &cobra.Command{ + Use: "upload", + Short: "上传本地文件为审批附件(初始化+PUT+提交,一步完成)", + Long: `上传本地文件为审批附件(三步自动完成)。 + +流程: + 1. 初始化上传信息,获取 OSS 上传地址与凭证 (oa/init_attachment_upload_info) + 2. HTTP PUT 上传文件二进制到 OSS + 3. 提交上传信息完成入库 (oa/commit_attachment_upload_info) + +--file-name 不传时默认用文件名;--md5 不传时自动计算。`, + Example: ` dws oa approval attachment upload --file ./合同.pdf + dws oa approval attachment upload --file ./report.xlsx --file-name Q1报表.xlsx + dws oa approval attachment upload --file ./data.bin --md5 d41d8cd98f00b204e9800998ecf8427e`, + RunE: runOAAttachmentUpload, + } + DeclareLeafMetadata(uploadCmd, LeafSpec{ + OutputRollout: output.RolloutUnifiedActive, + Safety: contract.SafetySpec{ + Effect: "write", Risk: "low", + Confirmation: "not_required", Idempotency: "unknown", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "attachment_upload", + CanonicalPath: "oa.attachment_upload", + CLIPath: "oa approval attachment upload", + PrimaryCLIPath: "oa approval attachment upload", + }, + Description: "上传本地文件为审批附件,一条命令完成初始化、HTTP PUT 与提交入库", + DryRun: &contract.DryRunSpec{PreviewKind: "plan", RemoteReads: false}, + Result: &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"审批附件上传提交结果","properties":{"spaceId":{"type":"integer","description":"审批附件所在钉盘空间 ID"},"fileName":{"type":"string","description":"文件名"},"fileSize":{"type":"integer","description":"文件字节数"},"class":{"type":"string","description":"服务端响应类型标识"},"fileType":{"type":"string","description":"文件类型"},"fileId":{"type":"string","description":"文件 ID"}},"required":["spaceId","fileName","fileSize","fileId"],"additionalProperties":true}`), + }, + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "命令包含多个 RPC(init/commit)与本地 HTTP PUT 步骤,不能绑定为单一 interface_ref", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "上传本地文件为审批附件,自动完成初始化+PUT+提交三步", + UseWhen: []string{"用户要把本地文件作为审批附件上传(首选一条命令自动完成凭证+PUT+提交)时"}, + AvoidWhen: []string{ + "仅需为已有附件授权下载或预览时使用 attachment authorize-download / authorize-preview", + "仅需获取已有附件临时下载链接时使用 attachment download-url", + }, + Examples: []string{"dws oa approval attachment upload --file ./合同.pdf --format json"}, + }, + }, + }) + uploadCmd.Flags().String("file", "", "本地文件路径 (必填)") + uploadCmd.Flags().String("file-name", "", "完整文件名,例如 合同.pdf (默认使用文件名)") + uploadCmd.Flags().String("md5", "", "文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算)") + _ = uploadCmd.MarkFlagRequired("file") + + attachmentCmd.AddCommand(downloadURLCmd, authorizeDownloadCmd, authorizePreviewCmd, uploadCmd) return attachmentCmd } +// oaAdminQueryMaxPageSize is the pageSize upper bound of +// get_process_instances_by_admin. +const oaAdminQueryMaxPageSize = float64(20) + +// validateOARequestProcessCode checks the processCode field of a decoded +// --request payload: the tool requires it, and the backend answers a bad +// processCode with success:true and an empty list, so reject it client-side. +func validateOARequestProcessCode(request map[string]any) error { + v, ok := request["processCode"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 processCode") + } + s, ok := v.(string) + if !ok || s == "" { + return fmt.Errorf("--request processCode 必须为非空字符串") + } + return nil +} + +// validateOARequestPageSize checks the pageSize field of a decoded +// --request payload (json.Number values from decodeOARequest). +func validateOARequestPageSize(request map[string]any) error { + v, ok := request["pageSize"] + if !ok { + return nil + } + n, ok := v.(json.Number) + if !ok { + return fmt.Errorf("pageSize 必须为数字") + } + f, err := n.Float64() + if err != nil || f < 1 || f > oaAdminQueryMaxPageSize { + return fmt.Errorf("pageSize 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), n.String()) + } + return nil +} + +// oaAdminTimeLayout is the startTime/endTime wire format of +// get_process_instances_by_admin since the 2026-08 MCP contract update +// (both fields changed from epoch millis to strings). +const oaAdminTimeLayout = "2006-01-02 15:04:05" + +// oaAdminTimeLayoutHint is the user-facing spelling of the layout used in +// error messages. +const oaAdminTimeLayoutHint = "yyyy-MM-dd HH:mm:ss" + +var oaAdminTimeZone = time.FixedZone("Asia/Shanghai", 8*3600) + +// formatOAAdminQueryTime renders a millisecond timestamp into the +// yyyy-MM-dd HH:mm:ss string required by get_process_instances_by_admin. +func formatOAAdminQueryTime(ms int64) string { + return time.UnixMilli(ms).In(oaAdminTimeZone).Format(oaAdminTimeLayout) +} + +// validateOARequestTimeRange checks startTime/endTime of a decoded +// --request payload: startTime is required, both must be +// yyyy-MM-dd HH:mm:ss strings, and endTime must be strictly after +// startTime (matching simple mode's ValidateTimeRange). +func validateOARequestTimeRange(request map[string]any) error { + startRaw, ok := request["startTime"] + if !ok { + return fmt.Errorf("--request 缺少必填字段 startTime") + } + startStr, ok := startRaw.(string) + if !ok { + return fmt.Errorf("startTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + start, err := time.ParseInLocation(oaAdminTimeLayout, startStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("startTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, startStr) + } + endRaw, ok := request["endTime"] + if !ok { + return nil + } + endStr, ok := endRaw.(string) + if !ok { + return fmt.Errorf("endTime 必须为 %s 格式字符串", oaAdminTimeLayoutHint) + } + end, err := time.ParseInLocation(oaAdminTimeLayout, endStr, oaAdminTimeZone) + if err != nil { + return fmt.Errorf("endTime 必须为 %s 格式,got: %s", oaAdminTimeLayoutHint, endStr) + } + if !end.After(start) { + return fmt.Errorf("--request endTime 必须晚于 startTime") + } + return nil +} + // ────────────────────────────────────────────────────────── // dws oa — OA 审批 // MCP tools(tools/list): list_pending_approvals, get_processInstance_detail, @@ -329,8 +703,9 @@ func newOAAttachmentCommand() *cobra.Command { // get_processInstance_records, list_initiated_instances, list_pending_tasks, // list_user_visible_process, append_task, search_form, oa_ding_user, revert_task, // get_inst_revert_activities, get_process_schema, forecast_process, -// start_process_instance, get_attachment_download_url, auth_download_file, -// auth_preview_attachment +// start_process_instance, get_process_instances_by_admin, +// get_attachment_download_url, auth_download_file, +// auth_preview_attachment, init_attachment_upload_info, commit_attachment_upload_info // ────────────────────────────────────────────────────────── func newOaCommand() *cobra.Command { @@ -1432,6 +1807,129 @@ func newOaCommand() *cobra.Command { }, }, }) + // 以管理员身份查询审批实例列表 + listByAdminSimpleFlags := []string{"process-code", "start", "end", "cursor", "limit", "user-ids", "statuses"} + approvalListByAdminCmd := &cobra.Command{ + Use: "list-by-admin", Short: "以管理员身份查询审批模板的实例列表", + Example: ` dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20,"statuses":["RUNNING"]}'`, + PreRunE: func(cmd *cobra.Command, args []string) error { + // Cobra 的 flag group 校验(英文报错)在 PreRunE 之后执行, + // 这里先校验同一组约束,保证用户看到的是中文错误。 + request, _ := cmd.Flags().GetString("request") + processCode, _ := cmd.Flags().GetString("process-code") + if request == "" && processCode == "" { + return fmt.Errorf("--request、--process-code 至少指定一个") + } + if request != "" { + for _, name := range listByAdminSimpleFlags { + if cmd.Flags().Changed(name) { + return fmt.Errorf("--request 与 --%s 不能同时指定", name) + } + } + } + if processCode != "" && !cmd.Flags().Changed("start") { + return fmt.Errorf("--process-code、--start 必须同时指定(缺少 --start)") + } + return nil + }, + RunE: func(cmd *cobra.Command, args []string) error { + if raw, _ := cmd.Flags().GetString("request"); raw != "" { + request, err := decodeOARequest(raw) + if err != nil { + return fmt.Errorf("--request JSON 解析失败: %w", err) + } + if err := validateOARequestProcessCode(request); err != nil { + return err + } + if err := validateOARequestPageSize(request); err != nil { + return err + } + if err := validateOARequestTimeRange(request); err != nil { + return err + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + } + if err := validateRequiredFlags(cmd, "process-code", "start"); err != nil { + return err + } + startMs, err := parseISOTimeToMillis("start", mustGetFlag(cmd, "start")) + if err != nil { + return err + } + cursor, err := strconv.ParseFloat(mustGetFlag(cmd, "cursor"), 64) + if err != nil { + return fmt.Errorf("--cursor 必须为数字: %w", err) + } + pageSize, err := strconv.ParseFloat(mustGetFlag(cmd, "limit"), 64) + if err != nil { + return fmt.Errorf("--limit 必须为数字: %w", err) + } + if pageSize < 1 || pageSize > oaAdminQueryMaxPageSize { + return fmt.Errorf("--limit 必须在 1-%d 之间,got: %s", int(oaAdminQueryMaxPageSize), mustGetFlag(cmd, "limit")) + } + request := map[string]any{ + "processCode": mustGetFlag(cmd, "process-code"), + "startTime": formatOAAdminQueryTime(startMs), + "cursor": cursor, + "pageSize": pageSize, + } + if v, _ := cmd.Flags().GetString("end"); v != "" { + endMs, err := parseISOTimeToMillis("end", v) + if err != nil { + return err + } + if err := validateTimeRange(startMs, endMs); err != nil { + return err + } + request["endTime"] = formatOAAdminQueryTime(endMs) + } + if v, _ := cmd.Flags().GetString("user-ids"); v != "" { + request["userIds"] = strings.Split(v, ",") + } + if v, _ := cmd.Flags().GetString("statuses"); v != "" { + request["statuses"] = strings.Split(v, ",") + } + return callMCPTool("get_process_instances_by_admin", map[string]any{"ProcessInstanceListQueryRequest": request}) + }, + } + DeclareLeafMetadata(approvalListByAdminCmd, LeafSpec{ + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", + Name: "get_process_instances_by_admin", + CanonicalPath: "oa.get_process_instances_by_admin", + CLIPath: "oa approval list-by-admin", + PrimaryCLIPath: "oa approval list-by-admin", + }, + Description: "以管理员身份获取审批单列表", + Interface: &contract.InterfaceSpec{ + Mode: "mcp", + Availability: "available", + Ref: &contract.InterfaceRefSpec{ProductID: "oa", RPCName: "get_process_instances_by_admin"}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "以管理员身份按模板、时间范围、状态与用户查询企业内审批实例列表", + UseWhen: []string{"具备 OA 审批管理员权限,需要跨用户统计或检索某模板下的审批实例时"}, + AvoidWhen: []string{ + "只查自己的待办/已办/已发起/抄送时改用 list-pending / list-executed / list-initiated / list-cc", + "无 OA 管理员权限时不要使用,该命令查不到数据", + }, + Examples: []string{ + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20", + "dws oa approval list-by-admin --process-code --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --statuses RUNNING,COMPLETED --user-ids \"userId1,userId2\"", + }, + }, + // Simple-mode flags are mapping exclusions (encoded inside ProcessInstanceListQueryRequest). + Parameters: []contract.ParamDecl{ + {Name: "request", Property: "ProcessInstanceListQueryRequest", InterfaceType: "object"}, + }, + }, + }) approvalCreateCmd := &cobra.Command{ Use: "create-instance", Short: "发起审批实例(需要 --yes 确认)", Example: "dws oa approval create-instance --process-code --form-values '{\"事由\":\"测试\"}' --yes", @@ -1610,6 +2108,27 @@ func newOaCommand() *cobra.Command { RequireTogether: [][]string{{"process-code", "dept-id", "form-values"}}, }) + approvalListByAdminCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("start", "", "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式使用;与 --request 互斥)") + approvalListByAdminCmd.Flags().String("end", "", "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选)") + approvalListByAdminCmd.Flags().String("cursor", "0", "分页游标,首次传 0") + approvalListByAdminCmd.Flags().String("limit", "20", "每页大小,最大 20") + approvalListByAdminCmd.Flags().String("user-ids", "", "按发起人 userId 过滤,多个用逗号分隔(可选)") + approvalListByAdminCmd.Flags().String("statuses", "", "按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED)") + approvalListByAdminCmd.Flags().String("request", "", "完整请求 JSON(高级模式;与简单模式参数互斥)") + approvalListByAdminCmd.MarkFlagsOneRequired("request", "process-code") + approvalListByAdminCmd.MarkFlagsRequiredTogether("process-code", "start") + listByAdminMutuallyExclusive := make([][]string, 0, len(listByAdminSimpleFlags)) + for _, name := range listByAdminSimpleFlags { + approvalListByAdminCmd.MarkFlagsMutuallyExclusive("request", name) + listByAdminMutuallyExclusive = append(listByAdminMutuallyExclusive, []string{"request", name}) + } + cli.AnnotateRuntimeConstraints(approvalListByAdminCmd, cli.RuntimeSchemaConstraints{ + MutuallyExclusive: listByAdminMutuallyExclusive, + RequireOneOf: [][]string{{"request", "process-code"}}, + RequireTogether: [][]string{{"process-code", "start"}}, + }) + approvalCreateCmd.Flags().String("process-code", "", "审批模板 processCode(简单模式使用;与 --request 互斥)") approvalCreateCmd.Flags().String("dept-id", "-1", "发起人部门 ID") approvalCreateCmd.Flags().String("form-values", "", "表单值 JSON(简单模式使用;与 --request 互斥)") @@ -1656,6 +2175,7 @@ func newOaCommand() *cobra.Command { approvalRevertTaskCmd, approvalFormSchemaCmd, approvalForecastCmd, + approvalListByAdminCmd, approvalCreateCmd, ) approvalCmd.AddCommand(newOAAttachmentCommand()) diff --git a/internal/helpers/oa_attachment_test.go b/internal/helpers/oa_attachment_test.go index 230e9021..d4173d05 100644 --- a/internal/helpers/oa_attachment_test.go +++ b/internal/helpers/oa_attachment_test.go @@ -10,7 +10,9 @@ import ( "errors" "io" "os" + "path/filepath" "reflect" + "strconv" "strings" "testing" @@ -30,6 +32,7 @@ func executeOAAttachmentCommandCapturingOutput(t *testing.T, caller *scriptedToo cmd := newOaCommand() cmd.PersistentFlags().Bool("yes", false, "跳过确认") + cmd.PersistentFlags().Bool("dry-run", false, "仅预览不执行") cmd.PersistentFlags().String("format", caller.Format(), "输出格式") ctx, _ := output.WithResultStore(context.Background()) cmd.SetContext(ctx) @@ -337,3 +340,846 @@ func TestCrossPlatformCoverageOAAttachmentRejectsInvalidPreviewFileIDs(t *testin }) } } + +const oaUploadInitResponse = `{"result":{"uploadKey":"key-123","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z","Authorization":"OSS signature"},"storageDriver":"oss"},"success":true}` + +func oaUploadCommitResponse(size int64) string { + return `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":` + + strconv.FormatInt(size, 10) + + `,"class":"com.dingtalk.oapi.response","fileType":"pdf","fileId":"file-abc"},"success":true}` +} + +// writeOAAttachmentTempFile 落地一个临时文件,返回其绝对路径与字节数。 +func writeOAAttachmentTempFile(t *testing.T, name, content string) (string, int64) { + t.Helper() + path := filepath.Join(t.TempDir(), name) + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + return path, int64(len(content)) +} + +// capturedPut 记录一次 httpPutFile 调用的入参,供断言 PUT 使用了解析出的 +// resourceURL 与签名 headers。 +type capturedPut struct { + calls int + url string + headers map[string]string + filePath string + fileSize int64 +} + +func mockOAAttachmentPut(t *testing.T) *capturedPut { + t.Helper() + put := &capturedPut{} + SetHTTPPutFile(func(_ context.Context, url string, headers map[string]string, filePath string, fileSize int64) error { + put.calls++ + put.url = url + put.headers = headers + put.filePath = filePath + put.fileSize = fileSize + return nil + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + return put +} + +func TestCrossPlatformCoverageOAAttachmentUploadEndToEnd(t *testing.T) { + const content = "pdf-bytes-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "source.pdf", content) + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: oaUploadCommitResponse(fileSize)}, + }} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "合同.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init+commit)", caller.calls) + } + if caller.toolLog[0] != "init_attachment_upload_info" || caller.serverLog[0] != "oa" { + t.Fatalf("first call = %s/%s, want oa/init_attachment_upload_info", caller.serverLog[0], caller.toolLog[0]) + } + wantInit := map[string]any{ + "fileName": "合同.pdf", + "fileSize": float64(fileSize), + "md5": "d41d8cd98f00b204e9800998ecf8427e", + } + if !reflect.DeepEqual(caller.argsLog[0], wantInit) { + t.Fatalf("init args = %#v, want %#v", caller.argsLog[0], wantInit) + } + + if put.calls != 1 { + t.Fatalf("httpPutFile called %d times, want 1", put.calls) + } + if put.url != "https://oss.example.test/upload" { + t.Fatalf("PUT url = %q, want parsed resourceURL", put.url) + } + if put.headers["Authorization"] != "OSS signature" || put.headers["x-oss-date"] != "20260820T000000Z" { + t.Fatalf("PUT headers = %#v, want parsed signed headers", put.headers) + } + if put.filePath != filePath || put.fileSize != fileSize { + t.Fatalf("PUT file = %q size = %d, want %q/%d", put.filePath, put.fileSize, filePath, fileSize) + } + + if caller.toolLog[1] != "commit_attachment_upload_info" { + t.Fatalf("second call = %s, want commit_attachment_upload_info", caller.toolLog[1]) + } + wantCommit := map[string]any{ + "fileName": "合同.pdf", + "uploadKey": "key-123", + "fileSize": float64(fileSize), + } + if !reflect.DeepEqual(caller.argsLog[1], wantCommit) { + t.Fatalf("commit args = %#v, want %#v", caller.argsLog[1], wantCommit) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["fileId"] != "file-abc" { + t.Fatalf("unified data = %#v, want fileId file-abc", envelope["data"]) + } +} + +func TestCrossPlatformCoverageOAAttachmentUploadAutoMD5AndDefaultName(t *testing.T) { + const content = "auto-md5-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "report.xlsx", content) + wantMD5, err := fileMD5Hex(filePath) + if err != nil { + t.Fatalf("compute expected md5: %v", err) + } + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: oaUploadCommitResponse(fileSize)}, + }} + // 既不传 --file-name 也不传 --md5:文件名应回退为 basename,md5 应自动计算。 + if _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + ); err != nil { + t.Fatalf("execute command: %v", err) + } + + if got := caller.argsLog[0]["fileName"]; got != "report.xlsx" { + t.Fatalf("default fileName = %#v, want basename report.xlsx", got) + } + if got := caller.argsLog[0]["md5"]; got != wantMD5 { + t.Fatalf("auto md5 = %#v, want computed %q", got, wantMD5) + } + if got := caller.argsLog[0]["fileSize"]; got != float64(fileSize) { + t.Fatalf("fileSize = %#v, want number %d", got, fileSize) + } +} + +func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitResponse(t *testing.T) { + tests := []struct { + name string + response string + wantErr string + }{ + { + name: "missing result", + response: `{"success":true}`, + wantErr: "缺少 result", + }, + { + name: "missing uploadKey", + response: `{"result":{"resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 uploadKey", + }, + { + name: "empty uploadKey", + response: `{"result":{"uploadKey":" ","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 uploadKey", + }, + { + name: "empty resourceUrls", + response: `{"result":{"uploadKey":"key-1","resourceUrls":[],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "缺少 resourceUrls", + }, + { + name: "empty resourceUrls element", + response: `{"result":{"uploadKey":"key-1","resourceUrls":[""],"headers":{"Authorization":"sig","x-oss-date":"d"}},"success":true}`, + wantErr: "首个 resourceUrls 为空", + }, + { + name: "missing headers", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"]},"success":true}`, + wantErr: "缺少 headers", + }, + { + name: "headers missing Authorization", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"x-oss-date":"20260820T000000Z"}},"success":true}`, + wantErr: "Authorization", + }, + { + name: "headers missing x-oss-date", + response: `{"result":{"uploadKey":"key-1","resourceUrls":["https://oss.example.test/upload"],"headers":{"Authorization":"OSS sig"}},"success":true}`, + wantErr: "x-oss-date", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "test.pdf", "data") + put := mockOAAttachmentPut(t) + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: test.response}, + // second step should never be reached + {text: `{"result":{},"success":true}`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "test.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only, no commit)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("httpPutFile called %d times, want 0 (should not PUT)", put.calls) + } + }) + } +} + +func TestCrossPlatformCoverageOAAttachmentUploadRequiredFlagValidation(t *testing.T) { + existing, _ := writeOAAttachmentTempFile(t, "exists.bin", "x") + tests := []struct { + name string + args []string + }{ + {name: "missing file", args: []string{"approval", "attachment", "upload"}}, + {name: "file does not exist", args: []string{"approval", "attachment", "upload", "--file", filepath.Join(existing, "missing.bin")}}, + {name: "file is directory", args: []string{"approval", "attachment", "upload", "--file", filepath.Dir(existing)}}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mockOAAttachmentPut(t) + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, test.args...) + if err == nil { + t.Fatalf("%s unexpectedly succeeded", test.name) + } + if caller.calls != 0 { + t.Fatalf("invalid upload made %d MCP call(s)", caller.calls) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut 验证 +// --dry-run 在任何远程调用(init/PUT/commit)之前 early return:零 MCP 调用、 +// 零 PUT,且输出一个包含 3 个 planned step 的 plan 预览。 +func TestCrossPlatformCoverageOAAttachmentUploadDryRunDoesNotCallRemoteOrPut(t *testing.T) { + const content = "dry-run-preview-content" + filePath, fileSize := writeOAAttachmentTempFile(t, "plan.pdf", content) + wantMD5, err := fileMD5Hex(filePath) + if err != nil { + t.Fatalf("compute expected md5: %v", err) + } + put := mockOAAttachmentPut(t) + + // caller.dry 驱动 deps.Caller.DryRun()==true;同时传入 --dry-run 以镜像生产路径。 + caller := &scriptedToolCaller{format: "json", dry: true} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--dry-run", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + if caller.calls != 0 { + t.Fatalf("dry-run made %d MCP call(s), want 0", caller.calls) + } + if put.calls != 0 { + t.Fatalf("dry-run made %d PUT call(s), want 0", put.calls) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v\noutput: %s", err, stdout) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified data missing/invalid: %#v", envelope["data"]) + } + if data["dry_run"] != true || data["executed"] != false || data["preview_kind"] != "plan" { + t.Fatalf("plan flags = %#v", data) + } + if data["operation"] != "attachment_upload" || data["source"] != "oa" { + t.Fatalf("plan operation/source = %#v", data) + } + if data["file_name"] != "plan.pdf" || data["file_size"] != float64(fileSize) || data["md5"] != wantMD5 { + t.Fatalf("plan file metadata = %#v (want file_name=plan.pdf size=%d md5=%s)", data, fileSize, wantMD5) + } + steps, ok := data["steps"].([]any) + if !ok || len(steps) != 3 { + t.Fatalf("plan steps = %#v, want 3 planned steps", data["steps"]) + } + wantTools := []string{"oa/init_attachment_upload_info", "HTTP PUT", "oa/commit_attachment_upload_info"} + for i, raw := range steps { + step, ok := raw.(map[string]any) + if !ok { + t.Fatalf("step %d not an object: %#v", i, raw) + } + if step["tool"] != wantTools[i] { + t.Fatalf("step %d tool = %#v, want %q", i, step["tool"], wantTools[i]) + } + if step["status"] != "planned" { + t.Fatalf("step %d status = %#v, want planned", i, step["status"]) + } + } + + // The commit step (index 2) must NOT contain uploadKey in args (remote-only field) + // and must declare the dependency via a "requires" entry. + commitStep := steps[2].(map[string]any) + commitArgs, _ := commitStep["args"].(map[string]any) + if _, hasUploadKey := commitArgs["uploadKey"]; hasUploadKey { + t.Fatalf("commit step args must not contain uploadKey, got: %#v", commitArgs) + } + requires, ok := commitStep["requires"].([]any) + if !ok || len(requires) == 0 { + t.Fatalf("commit step must have non-empty requires, got: %#v", commitStep["requires"]) + } + found := false + for _, r := range requires { + if s, ok := r.(string); ok && (len(s) > 0 && strings.Contains(s, "uploadKey") && strings.Contains(s, "init")) { + found = true + break + } + } + if !found { + t.Fatalf("commit step requires should mention uploadKey and init, got: %#v", requires) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadMD5Failure injects a failing computeFileMD5 to +// cover runOAAttachmentUpload's MD5 failure branch on all platforms (including Windows). +func TestCrossPlatformCoverageOAAttachmentUploadMD5Failure(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "test.bin", "hello") + put := mockOAAttachmentPut(t) + + testseam.Swap(t, &computeFileMD5, func(string) (string, error) { + return "", errors.New("permission denied") + }) + + caller := &scriptedToolCaller{format: "json"} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + ) + if err == nil { + t.Fatal("expected error from MD5 failure, got nil") + } + if !strings.Contains(err.Error(), "MD5") { + t.Fatalf("error should mention MD5, got: %v", err) + } + if caller.calls != 0 { + t.Fatalf("expected 0 MCP calls when MD5 fails, got %d", caller.calls) + } + if put.calls != 0 { + t.Fatalf("expected 0 PUT calls when MD5 fails, got %d", put.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError 注入一个返回错误的 PUT, +// 覆盖 runOAAttachmentUpload 中 httpPutFile 失败分支:init 后报错(1 次 MCP 调用)且不提交。 +func TestCrossPlatformCoverageOAAttachmentUploadHTTPPutError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "put-fail.pdf", "data") + SetHTTPPutFile(func(context.Context, string, map[string]string, string, int64) error { + return errors.New("oss put failed") + }) + t.Cleanup(func() { SetHTTPPutFile(nil) }) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + // commit 不应被达到 + {text: `{"result":{},"success":true}`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "put-fail.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "oss put failed") { + t.Fatalf("error = %v, want oss put failed", err) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only, no commit after PUT failure)", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse 校验 commit 返回 +// 缺少必需字段时命令报错(而不是包装为成功)。 +func TestCrossPlatformCoverageOAAttachmentUploadMalformedCommitResponse(t *testing.T) { + tests := []struct { + name string + commitResponse string + wantErr string + }{ + { + name: "result is null", + commitResponse: `{"success":true,"result":null}`, + wantErr: "不是有效的 JSON 对象", + }, + { + name: "result is empty object", + commitResponse: `{"success":true,"result":{}}`, + wantErr: "spaceId", + }, + { + name: "missing fileId", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100}}`, + wantErr: "fileId", + }, + { + name: "empty fileId", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":100,"fileId":""}}`, + wantErr: "fileId", + }, + { + name: "missing spaceId", + commitResponse: `{"success":true,"result":{"fileName":"a.pdf","fileSize":100,"fileId":"file-1"}}`, + wantErr: "spaceId", + }, + { + name: "fileSize is zero", + commitResponse: `{"success":true,"result":{"spaceId":"123","fileName":"a.pdf","fileSize":0,"fileId":"file-1"}}`, + wantErr: "fileSize", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-validate.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: test.commitResponse}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-validate.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + // init + commit = 2 MCP calls + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls) + } + // PUT should have been called (init succeeded) + if put.calls != 1 { + t.Fatalf("httpPutFile called %d times, want 1", put.calls) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect 直接调用 +// validateOAAttachmentCommitResult 覆盖所有分支路径,包括通过 end-to-end 流不可达的 +// json.Number 类型分支。 +func TestCrossPlatformCoverageOAAttachmentValidateCommitResultDirect(t *testing.T) { + tests := []struct { + name string + input any + wantErr string // empty means expect nil error + }{ + // result 不是 map + {name: "result is string", input: "hello", wantErr: "不是有效的 JSON 对象"}, + {name: "result is array", input: []any{"x"}, wantErr: "不是有效的 JSON 对象"}, + {name: "result is number", input: float64(42), wantErr: "不是有效的 JSON 对象"}, + {name: "result is nil", input: nil, wantErr: "不是有效的 JSON 对象"}, + + // spaceId — 空字符串 + {name: "spaceId empty string", input: map[string]any{ + "spaceId": "", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + {name: "spaceId whitespace only", input: map[string]any{ + "spaceId": " ", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + + // spaceId — json.Number(通过 UseNumber 解码的数字) + {name: "spaceId json.Number", input: map[string]any{ + "spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: ""}, + + // spaceId — float64(正常路径) + {name: "spaceId float64", input: map[string]any{ + "spaceId": float64(123), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: ""}, + + // spaceId — 非法类型 + {name: "spaceId bool", input: map[string]any{ + "spaceId": true, "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "spaceId"}, + + // fileName — 非 string 类型 + {name: "fileName is number", input: map[string]any{ + "spaceId": "123", "fileName": float64(99), "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + {name: "fileName is nil", input: map[string]any{ + "spaceId": "123", "fileName": nil, "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + {name: "fileName empty", input: map[string]any{ + "spaceId": "123", "fileName": "", "fileSize": float64(100), "fileId": "file-1", + }, wantErr: "fileName"}, + + // fileSize — json.Number 有效 + {name: "fileSize json.Number valid", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("200"), "fileId": "file-1", + }, wantErr: ""}, + + // fileSize — json.Number 无效(<= 0) + {name: "fileSize json.Number zero", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("0"), "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize json.Number negative", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("-5"), "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize json.Number invalid", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": json.Number("abc"), "fileId": "file-1", + }, wantErr: "fileSize"}, + + // fileSize — 非法类型(default 分支) + {name: "fileSize is string", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": "100", "fileId": "file-1", + }, wantErr: "fileSize"}, + {name: "fileSize is nil", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": nil, "fileId": "file-1", + }, wantErr: "fileSize"}, + + // fileId — 非 string 类型 + {name: "fileId is number", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": float64(99), + }, wantErr: "fileId"}, + {name: "fileId whitespace", input: map[string]any{ + "spaceId": "123", "fileName": "a.pdf", "fileSize": float64(100), "fileId": " ", + }, wantErr: "fileId"}, + + // 全部通过 + {name: "all valid string types", input: map[string]any{ + "spaceId": "123", "fileName": "report.pdf", "fileSize": float64(512), "fileId": "file-abc", + }, wantErr: ""}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := validateOAAttachmentCommitResult(test.input) + if test.wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath 覆盖 runOAAttachmentUpload +// 中 --file 传了但值为空字符串的分支(cobra MarkFlagRequired 只拦截未传,不拦截空值)。 +func TestCrossPlatformCoverageOAAttachmentUploadEmptyFilePath(t *testing.T) { + mockOAAttachmentPut(t) + caller := &scriptedToolCaller{format: "json"} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", "", + ) + if err == nil || !strings.Contains(err.Error(), "--file 不能为空") { + t.Fatalf("error = %v, want --file empty error", err) + } + if caller.calls != 0 { + t.Fatalf("MCP calls = %d, want 0", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadInitCallError 覆盖 runOAAttachmentUpload +// 中 callMCPToolReturnTextOnServer(init 步)返回错误的分支。 +func TestCrossPlatformCoverageOAAttachmentUploadInitCallError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "init-err.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {err: errors.New("injected init failure")}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "init-err.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil { + t.Fatal("expected error from init call failure, got nil") + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init attempted)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("PUT calls = %d, want 0", put.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject 覆盖 runOAAttachmentUpload +// 中 commitData 不是 map[string]any 的分支(line 348)。 +func TestCrossPlatformCoverageOAAttachmentUploadCommitNonObject(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-nonobj.pdf", "data") + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + // commit 返回一个 JSON 字符串而非对象 + {text: `"not an object"`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-nonobj.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "不是 JSON 对象") { + t.Fatalf("error = %v, want commit non-object error", err) + } + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit)", caller.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON 覆盖 +// parseOAAttachmentUploadInfo 中 json.Unmarshal 失败分支(init 返回非法 JSON)。 +func TestCrossPlatformCoverageOAAttachmentUploadMalformedInitJSON(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "bad-init.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: `{not valid json`}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "bad-init.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "解析 init_attachment_upload_info 返回失败") { + t.Fatalf("error = %v, want parse failure", err) + } + if caller.calls != 1 { + t.Fatalf("MCP calls = %d, want 1 (init only)", caller.calls) + } + if put.calls != 0 { + t.Fatalf("PUT calls = %d, want 0", put.calls) + } +} + +// TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization 验证 +// validateOAAttachmentCommitResult 对 spaceId/fileSize 的归一化行为。 +func TestCrossPlatformCoverageOAAttachmentValidateCommitResultNormalization(t *testing.T) { + tests := []struct { + name string + input map[string]any + wantSpaceID any + wantSize any + wantErr string + }{ + { + name: "string spaceId normalized to int64", + input: map[string]any{"spaceId": "27827223951", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: int64(27827223951), + wantSize: float64(100), + }, + { + name: "non-numeric string spaceId returns error", + input: map[string]any{"spaceId": "abc", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "float string spaceId returns error", + input: map[string]any{"spaceId": "12.5", "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "json.Number spaceId normalized to int64", + input: map[string]any{"spaceId": json.Number("27827223951"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: int64(27827223951), + wantSize: float64(100), + }, + { + name: "json.Number spaceId non-integer returns error", + input: map[string]any{"spaceId": json.Number("12.5"), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantErr: "spaceId", + }, + { + name: "json.Number fileSize normalized to int64", + input: map[string]any{"spaceId": float64(123), "fileName": "a.pdf", "fileSize": json.Number("2048"), "fileId": "file-1"}, + wantSpaceID: float64(123), + wantSize: int64(2048), + }, + { + name: "float64 spaceId unchanged", + input: map[string]any{"spaceId": float64(27827223951), "fileName": "a.pdf", "fileSize": float64(100), "fileId": "file-1"}, + wantSpaceID: float64(27827223951), + wantSize: float64(100), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + normalized, err := validateOAAttachmentCommitResult(test.input) + if test.wantErr != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", test.wantErr) + } + if !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want substring %q", err, test.wantErr) + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if normalized["spaceId"] != test.wantSpaceID { + t.Fatalf("spaceId = %v (%T), want %v (%T)", normalized["spaceId"], normalized["spaceId"], test.wantSpaceID, test.wantSpaceID) + } + if normalized["fileSize"] != test.wantSize { + t.Fatalf("fileSize = %v (%T), want %v (%T)", normalized["fileSize"], normalized["fileSize"], test.wantSize, test.wantSize) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadOutputContract 验证端到端上传命令 +// 输出 JSON 中 spaceId 始终为 number(即使 commit 返回字符串 spaceId)。 +func TestCrossPlatformCoverageOAAttachmentUploadOutputContract(t *testing.T) { + tests := []struct { + name string + commitResponse string + wantSpaceID float64 // JSON decode 后 number → float64 + }{ + { + name: "spaceId as number", + commitResponse: `{"result":{"spaceId":27827223951,"fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`, + wantSpaceID: float64(27827223951), + }, + { + name: "spaceId as string", + commitResponse: `{"result":{"spaceId":"27827223951","fileName":"合同.pdf","fileSize":17,"fileType":"pdf","fileId":"file-abc"},"success":true}`, + wantSpaceID: float64(27827223951), + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + const content = "output-contract-data" + filePath, _ := writeOAAttachmentTempFile(t, "contract.pdf", content) + mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {text: test.commitResponse}, + }} + stdout, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "合同.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err != nil { + t.Fatalf("execute command: %v", err) + } + + var envelope map[string]any + if err := json.Unmarshal([]byte(stdout), &envelope); err != nil { + t.Fatalf("decode unified output: %v", err) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("unified envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok { + t.Fatalf("unified data missing: %#v", envelope) + } + // spaceId must be a number in the JSON output + spaceID, ok := data["spaceId"].(float64) + if !ok { + t.Fatalf("spaceId is not a number: %v (%T)", data["spaceId"], data["spaceId"]) + } + if spaceID != test.wantSpaceID { + t.Fatalf("spaceId = %v, want %v", spaceID, test.wantSpaceID) + } + // fileSize must also be a number + if _, ok := data["fileSize"].(float64); !ok { + t.Fatalf("fileSize is not a number: %v (%T)", data["fileSize"], data["fileSize"]) + } + }) + } +} + +// TestCrossPlatformCoverageOAAttachmentUploadCommitError 脚本化 init 返回合法、 +// commit 返回错误,覆盖 callOAAttachmentResultCtx(commit 步)失败分支: +// PUT 发生一次,init+commit 均尝试(共 2 次 MCP 调用),命令报错。 +func TestCrossPlatformCoverageOAAttachmentUploadCommitError(t *testing.T) { + filePath, _ := writeOAAttachmentTempFile(t, "commit-fail.pdf", "data") + put := mockOAAttachmentPut(t) + + caller := &scriptedToolCaller{format: "json", steps: []scriptedToolStep{ + {text: oaUploadInitResponse}, + {err: errors.New("commit upload failed")}, + }} + _, err := executeOAAttachmentCommandCapturingOutput(t, caller, + "approval", "attachment", "upload", + "--file", filePath, + "--file-name", "commit-fail.pdf", + "--md5", "d41d8cd98f00b204e9800998ecf8427e", + ) + if err == nil || !strings.Contains(err.Error(), "commit upload failed") { + t.Fatalf("error = %v, want commit upload failed", err) + } + if put.calls != 1 { + t.Fatalf("PUT calls = %d, want 1", put.calls) + } + if caller.calls != 2 { + t.Fatalf("MCP calls = %d, want 2 (init + commit attempted)", caller.calls) + } + if caller.toolLog[0] != "init_attachment_upload_info" || caller.toolLog[1] != "commit_attachment_upload_info" { + t.Fatalf("tool sequence = %v, want init then commit", caller.toolLog) + } +} diff --git a/internal/helpers/oa_coverage_more_test.go b/internal/helpers/oa_coverage_more_test.go index 70cb18cc..019b64e8 100644 --- a/internal/helpers/oa_coverage_more_test.go +++ b/internal/helpers/oa_coverage_more_test.go @@ -131,6 +131,51 @@ func TestCrossPlatformCoverageOAApprovalCreateInstanceRequiresExplicitYes(t *tes } } +func TestCrossPlatformCoverageOAApprovalListByAdminMapsSimpleOptions(t *testing.T) { + caller := &scriptedToolCaller{} + err := executeOACommand(t, caller, + "approval", "list-by-admin", + "--process-code", "PROC", + "--start", "2030-01-01T09:00:00+08:00", + "--end", "2030-01-01T10:00:00+08:00", + "--cursor", "5", + "--limit", "20", + "--user-ids", "user-1,user-2", + "--statuses", "RUNNING,COMPLETED", + ) + if err != nil { + t.Fatalf("list by admin: %v", err) + } + if caller.server != "oa" || caller.tool != "get_process_instances_by_admin" { + t.Fatalf("called %s/%s, want oa/get_process_instances_by_admin", caller.server, caller.tool) + } + request, ok := caller.args["ProcessInstanceListQueryRequest"].(map[string]any) + if !ok { + t.Fatalf("request payload = %#v", caller.args) + } + if got := request["processCode"]; got != "PROC" { + t.Fatalf("processCode = %#v", got) + } + if got := request["cursor"]; got != float64(5) { + t.Fatalf("cursor = %#v", got) + } + if got := request["pageSize"]; got != float64(20) { + t.Fatalf("pageSize = %#v", got) + } + if got := request["startTime"]; got != "2030-01-01 09:00:00" { + t.Fatalf("startTime = %#v", got) + } + if got := request["endTime"]; got != "2030-01-01 10:00:00" { + t.Fatalf("endTime = %#v", got) + } + if got := request["userIds"]; len(got.([]string)) != 2 || got.([]string)[0] != "user-1" || got.([]string)[1] != "user-2" { + t.Fatalf("userIds = %#v", got) + } + if got := request["statuses"]; len(got.([]string)) != 2 || got.([]string)[0] != "RUNNING" || got.([]string)[1] != "COMPLETED" { + t.Fatalf("statuses = %#v", got) + } +} + func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *testing.T) { validCases := []struct { name string @@ -157,6 +202,26 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t args: []string{"approval", "create-instance", "--request", `{"processCode":"PROC"}`, "--yes"}, tool: "start_process_instance", }, + { + name: "list-by-admin simple mode", + args: []string{"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00"}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode without pageSize", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0}`}, + tool: "get_process_instances_by_admin", + }, + { + name: "list-by-admin request mode with endTime", + args: []string{"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 23:59:59","cursor":0,"pageSize":20}`}, + tool: "get_process_instances_by_admin", + }, } for _, tc := range validCases { t.Run(tc.name, func(t *testing.T) { @@ -188,6 +253,31 @@ func TestCrossPlatformCoverageOAApprovalNewCommandValidationAndRequestModes(t *t {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--dept-id", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--approvers", "u", "--approvers-action-type", "bad", "--yes"}, {"approval", "create-instance", "--process-code", "PROC", "--form-values", `{}`, "--cc-list", "u", "--cc-position", "bad", "--yes"}, + {"approval", "list-by-admin"}, + {"approval", "list-by-admin", "--process-code", "PROC"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T10:00:00+08:00", "--end", "2030-01-01T09:00:00+08:00"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--cursor", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "bad"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "21"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", "2030-01-01T09:00:00+08:00", "--limit", "0"}, + {"approval", "list-by-admin", "--process-code", "PROC", "--start", ""}, + {"approval", "list-by-admin", "--request", "{"}, + {"approval", "list-by-admin", "--request", "null"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC"}`, "--process-code", "PROC"}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":21}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":"20"}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":1893459600000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 10:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":1893463200000,"cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","endTime":"NOT-A-TIME","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"PROC","startTime":"2030-01-01 09:00:00","endTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":"","startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, + {"approval", "list-by-admin", "--request", `{"processCode":123,"startTime":"2030-01-01 09:00:00","cursor":0,"pageSize":20}`}, } for _, args := range invalidCases { caller := &scriptedToolCaller{} diff --git a/internal/helpers/polling_coverage_more_test.go b/internal/helpers/polling_coverage_more_test.go index 8b58bdbf..5cc02f26 100644 --- a/internal/helpers/polling_coverage_more_test.go +++ b/internal/helpers/polling_coverage_more_test.go @@ -27,6 +27,11 @@ type scriptedToolCaller struct { server string tool string args map[string]any + // Per-call logs so multi-step flows (e.g. OA attachment upload's init+commit) + // can assert each invocation instead of only the last one captured above. + serverLog []string + toolLog []string + argsLog []map[string]any } func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName string, args map[string]any) (*edition.ToolResult, error) { @@ -34,6 +39,9 @@ func (c *scriptedToolCaller) CallTool(_ context.Context, serverID, toolName stri c.server = serverID c.tool = toolName c.args = args + c.serverLog = append(c.serverLog, serverID) + c.toolLog = append(c.toolLog, toolName) + c.argsLog = append(c.argsLog, args) if len(c.steps) == 0 { return &edition.ToolResult{}, nil } diff --git a/internal/helpers/recruit.go b/internal/helpers/recruit.go new file mode 100644 index 00000000..cb2979c9 --- /dev/null +++ b/internal/helpers/recruit.go @@ -0,0 +1,678 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + + "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/output" +) + +const ( + recruitServerID = "recruit" + recruitCreateJobTool = "create_job" + recruitGetJobTool = "get_job_detail" + recruitListJobsTool = "list_jobs" +) + +var recruitDryRun = &contract.DryRunSpec{ + PreviewKind: contract.DryRunPreviewRequest, + RemoteReads: false, +} + +var ( + recruitListResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"当前页招聘职位查询结果;续页信息位于 meta.pagination","properties":{"jobs":{"type":"array","description":"当前页职位记录","items":{"type":"object","description":"招聘职位摘要","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}}},"additionalProperties":true}`), + } + recruitJobDetailResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位详情","properties":{"jobId":{"type":"string","description":"职位 ID"},"name":{"type":"string","description":"职位名称"},"description":{"type":"string","description":"职位描述"},"status":{"type":"number","description":"职位状态枚举值"}},"additionalProperties":true}`), + } + recruitCreateJobResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"招聘职位创建结果","properties":{"jobId":{"type":"string","description":"新创建的职位 ID"}},"required":["jobId"],"additionalProperties":true}`), + } +) + +func recruitCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{Kind: contract.PaginationKindCursor, CursorParameter: "cursor"} +} + +func newRecruitCommand() *cobra.Command { + contract.RegisterProductDecl(contract.ProductDecl{ + ID: "recruit", + Selection: contract.ProductSelectionDecl{ + AgentSummary: "查询和创建钉钉招聘职位", + UseWhen: []string{"需要查询招聘职位列表、查看职位详情或创建职位时"}, + AvoidWhen: []string{"查询企业员工与组织架构使用 contact;查询人才池、职业历程或绩效使用 hrbrain"}, + }, + }) + + root := &cobra.Command{ + Use: "recruit", + Short: "钉钉招聘", + Long: "查询和创建钉钉招聘中的职位信息。", + RunE: groupRunE, + } + job := &cobra.Command{ + Use: "job", + Short: "招聘职位管理", + RunE: groupRunE, + } + job.AddCommand( + newRecruitJobListCommand(), + newRecruitJobGetCommand(), + newRecruitJobCreateCommand(), + ) + root.AddCommand(job) + return root +} + +func newRecruitJobListCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "list", + Short: "查询招聘职位列表", + Long: "按职位 ID、关键词、状态、创建人、职位性质等条件分页查询招聘职位。", + Example: " dws recruit job list --keyword Java --status open --size 20 --format json\n dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json", + Server: recruitServerID, + Tool: recruitListJobsTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + Flags: []LeafFlag{ + {Name: "job-ids", Usage: "职位 ID,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "jobIds"}, + {Name: "required-edu", Usage: "学历要求枚举值", Kind: LeafInt, Bind: "requiredEdu"}, + {Name: "status", Usage: "职位状态:draft/open/invalid/closed,多个值用逗号分隔", Bind: "statusList", Trim: true, OmitEmpty: true, Transform: transformRecruitStatuses}, + {Name: "job-nature", Usage: "职位性质", Bind: "jobNature", Trim: true, OmitEmpty: true}, + {Name: "campus", Usage: "是否为校园招聘", Kind: LeafBool, Bind: "campus"}, + {Name: "start-modified-time", Usage: "修改时间范围起点", Bind: "startModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "end-modified-time", Usage: "修改时间范围终点", Bind: "endModifiedTime", Trim: true, OmitEmpty: true}, + {Name: "creator-user-ids", Usage: "创建人 userId,多个值用逗号分隔", Kind: LeafStringSlice, Bind: "creatorUserIds"}, + {Name: "keyword", Usage: "职位搜索关键词", Bind: "keyword", Trim: true, OmitEmpty: true}, + {Name: "category", Usage: "职位分类", Bind: "category", Trim: true, OmitEmpty: true}, + {Name: "cursor", Usage: "分页游标;首次查询不传,翻页时原样回填返回的 nextCursor", Bind: "cursor", Trim: true, OmitEmpty: true, Transform: transformRecruitCursor}, + {Name: "size", Usage: "分页大小,默认 20", Kind: LeafInt, Bind: "size", Default: "20", ArgDefault: "20"}, + }, + Validate: validateRecruitList, + ResultCall: recruitResultCall, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitListJobsTool, CanonicalPath: "recruit.list_jobs", CLIPath: "recruit job list", PrimaryCLIPath: "recruit job list"}, + Description: "按条件分页查询招聘职位", + DryRun: recruitDryRun, + Result: recruitListResult, + Pagination: recruitCursorPagination(), + Interface: recruitMCPInterface(recruitListJobsTool), + Selection: contract.SelectionSpec{ + AgentSummary: "按关键词、状态、创建人等条件分页查询招聘职位", + UseWhen: []string{"需要查找职位、筛选在招职位或取得 jobId 时"}, + AvoidWhen: []string{"已经持有明确 jobId 且需要完整详情时使用 recruit job get"}, + Examples: []string{`dws recruit job list --keyword "Java" --status open --size 20 --format json`}, + }, + Parameters: recruitListParamDecls(), + }, + }) +} + +func recruitListToolArgs(args map[string]any) map[string]any { + params := map[string]any{"param": map[string]any{}, "size": args["size"]} + query := params["param"].(map[string]any) + for key, value := range args { + switch key { + case "cursor": + params[key] = value + case "size": + default: + query[key] = value + } + } + return params +} + +func recruitResultCall(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + toolArgs := args + if tool == recruitListJobsTool { + toolArgs = recruitListToolArgs(args) + } + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "tool": tool, "arguments": toolArgs, "executed": false, + }, output.WithDryRun()), nil + } + data, err := callRecruitMCPToolData(cmd.Context(), tool, toolArgs) + if err != nil { + return nil, err + } + clean, err := recruitBusinessResultData(data, tool) + if err != nil { + return recruitResponseFailure(err), nil + } + switch tool { + case recruitListJobsTool: + listData, meta, err := recruitListResultData(clean) + if err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(listData, output.WithMeta(meta)), nil + case recruitGetJobTool, recruitCreateJobTool: + if err := validateRecruitJobResult(clean, tool, args); err != nil { + return recruitInvalidResponse(err), nil + } + return output.Success(clean), nil + default: + return recruitInvalidResponse(fmt.Errorf("不支持校验 %s 的业务结果", tool)), nil + } +} + +func validateRecruitJobResult(data map[string]any, tool string, args map[string]any) error { + jobID, ok := data["jobId"].(string) + if !ok || strings.TrimSpace(jobID) == "" { + return fmt.Errorf("%s 返回值缺少非空字符串字段 jobId", tool) + } + if tool != recruitGetJobTool { + return nil + } + requestedJobID, ok := args["jobId"].(string) + if !ok || strings.TrimSpace(requestedJobID) == "" { + return fmt.Errorf("%s 请求缺少非空字符串字段 jobId", tool) + } + if strings.TrimSpace(jobID) != strings.TrimSpace(requestedJobID) { + return fmt.Errorf("%s 返回的 jobId %q 与请求的 jobId %q 不一致", tool, jobID, requestedJobID) + } + return nil +} + +func callRecruitMCPToolData(ctx context.Context, tool string, args map[string]any) (any, error) { + text, err := callMCPToolReturnTextOnServer(ctx, recruitServerID, tool, args) + if err != nil { + return nil, err + } + if strings.TrimSpace(text) == "" { + return map[string]any{}, nil + } + var data any + decoder := json.NewDecoder(strings.NewReader(text)) + decoder.UseNumber() + if err := decoder.Decode(&data); err != nil { + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + err = fmt.Errorf("存在多个 JSON 值") + } + return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err)) + } + return data, nil +} + +func recruitResponseFailure(err error) output.CommandResult { + var businessFailure *recruitBusinessFailure + if errors.As(err, &businessFailure) { + return output.Failure(&output.ErrorInfo{ + Type: "api", Message: businessFailure.Error(), + }) + } + return recruitInvalidResponse(err) +} + +type recruitBusinessFailure struct { + message string +} + +func (e *recruitBusinessFailure) Error() string { + return e.message +} + +func recruitBusinessResultData(data any, tool string) (map[string]any, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值必须是 JSON 对象", tool) + } + successValue, hasSuccess := object["success"] + resultValue, hasResult := object["result"] + if !hasSuccess && !hasResult { + return object, nil + } + if !hasSuccess || !hasResult { + return nil, fmt.Errorf("%s 返回的 Connector 信封必须同时包含 success 和 result", tool) + } + success, ok := successValue.(bool) + if !ok { + return nil, fmt.Errorf("%s 返回的 Connector 信封字段 success 必须是布尔值", tool) + } + if !success { + message, _ := object["message"].(string) + if strings.TrimSpace(message) == "" { + message = "Connector 返回 success=false" + } + return nil, &recruitBusinessFailure{message: fmt.Sprintf("%s 调用失败: %s", tool, message)} + } + result, ok := resultValue.(map[string]any) + if !ok { + return nil, fmt.Errorf("%s 返回值的 result 必须是 JSON 对象", tool) + } + return result, nil +} + +func recruitInvalidResponse(err error) output.CommandResult { + return output.Failure(&output.ErrorInfo{ + Type: "api", Subtype: "invalid_response", Message: err.Error(), + Hint: "保留原始响应并停止后续操作;不要依据不完整结果继续处理。", + }) +} + +func recruitListResultData(data any) (any, *output.Meta, error) { + object, ok := data.(map[string]any) + if !ok { + return nil, nil, fmt.Errorf("%s 返回值必须是 JSON 对象", recruitListJobsTool) + } + // The Connector envelope has already been removed by recruitResultCall. + // Only normalize the list business payload here so fields named success or + // result inside that payload cannot be mistaken for another envelope. + hasMore, ok := object["hasMore"].(bool) + if !ok { + return nil, nil, fmt.Errorf("list_jobs 返回值缺少布尔字段 hasMore") + } + nextToken := "" + if hasMore { + if raw, exists := object["nextCursor"]; exists && raw != nil { + var err error + nextToken, err = normalizeRecruitNextCursor(raw) + if err != nil { + return nil, nil, err + } + } + if nextToken == "" { + return nil, nil, fmt.Errorf("list_jobs 返回 hasMore=true 但缺少 nextCursor") + } + } + clean := make(map[string]any, len(object)) + for key, value := range object { + if key != "hasMore" && key != "nextCursor" { + clean[key] = value + } + } + if jobs, exists := clean["list"]; exists { + if _, alreadyNormalized := clean["jobs"]; !alreadyNormalized { + clean["jobs"] = jobs + } + delete(clean, "list") + } + pagination := &output.Pagination{EndpointExhausted: !hasMore, NextToken: nextToken, Pages: 1} + meta := &output.Meta{Pagination: pagination} + if jobs, exists := clean["jobs"].([]any); exists { + meta.Count = output.NewCount(len(jobs)) + pagination.Items = len(jobs) + } + return clean, meta, nil +} + +func normalizeRecruitNextCursor(raw any) (string, error) { + var value string + switch cursor := raw.(type) { + case string: + value = strings.TrimSpace(cursor) + case json.Number: + value = string(cursor) + case float64: + value = strconv.FormatFloat(cursor, 'f', -1, 64) + default: + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是字符串或数字") + } + parsed, err := strconv.ParseInt(value, 10, 64) + if err != nil || parsed < 0 { + return "", fmt.Errorf("list_jobs 的 nextCursor 必须是可回填的非负十进制 int64 游标") + } + return strconv.FormatInt(parsed, 10), nil +} + +func newRecruitJobGetCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "get", + Short: "查询招聘职位详情", + Long: "根据职位 ID 查询招聘职位详情。", + Example: " dws recruit job get --job-id JOB_ID --format json", + Server: recruitServerID, + Tool: recruitGetJobTool, + Safety: recruitSafetyRead(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, + Flags: []LeafFlag{{ + Name: "job-id", Usage: "职位 ID(必填)", Bind: "jobId", Trim: true, + Required: true, RequiredHint: "--job-id 为必填", + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitGetJobTool, CanonicalPath: "recruit.get_job_detail", CLIPath: "recruit job get", PrimaryCLIPath: "recruit job get"}, + Description: "根据职位 ID 查询招聘职位详情", + DryRun: recruitDryRun, + Result: recruitJobDetailResult, + Interface: recruitMCPInterface(recruitGetJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "获取指定招聘职位的完整信息", + UseWhen: []string{"已经持有明确 jobId,需要查看职位详情时"}, + AvoidWhen: []string{"不知道 jobId 时先使用 recruit job list 查询"}, + Examples: []string{"dws recruit job get --job-id --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "job-id", Property: "jobId", Required: boolPtr(true), InterfaceType: "string"}}, + }, + }) +} + +func newRecruitJobCreateCommand() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "create", + Short: "创建招聘职位", + Long: "从 JSON 文件读取职位信息并创建招聘职位。该操作会写入远端招聘系统,执行前必须确认。", + Example: " dws recruit job create --from ./job.json --dry-run --format json\n dws recruit job create --from ./job.json --format json", + Server: recruitServerID, + Tool: recruitCreateJobTool, + Safety: recruitSafetyCreate(), + OutputRollout: output.RolloutUnifiedActive, + ResultCall: recruitResultCall, + Flags: []LeafFlag{{ + Name: "from", Usage: "职位 JSON 文件路径(必填)", Bind: "atsAddJobParam", Trim: true, + Required: true, RequiredHint: "--from 为必填", Transform: loadRecruitJobFile, + }}, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ProductID: "recruit", Name: recruitCreateJobTool, CanonicalPath: "recruit.create_job", CLIPath: "recruit job create", PrimaryCLIPath: "recruit job create"}, + Description: "从 JSON 文件创建招聘职位", + DryRun: recruitDryRun, + Result: recruitCreateJobResult, + Interface: recruitMCPInterface(recruitCreateJobTool), + Selection: contract.SelectionSpec{ + AgentSummary: "使用结构化 JSON 创建招聘职位", + UseWhen: []string{"用户明确要求新建职位,并已准备或同意生成职位 JSON 时;文件必须包含 name、description、jobNature、requiredEdu、extData、creatorUserId;jobNature 固定为 FULL-TIME;creatorUserId 必须使用真实创建人 userId;ownerUserIds 可选"}, + AvoidWhen: []string{"仅查询职位时使用 recruit job list 或 recruit job get"}, + Examples: []string{"dws recruit job create --from ./job.json --dry-run --format json"}, + }, + Parameters: []contract.ParamDecl{{Name: "from", Property: "atsAddJobParam", Required: boolPtr(true), InterfaceType: "object", Description: "职位 JSON 文件;CLI 校验后原样作为 atsAddJobParam 对象发送;creatorUserId 为必填的创建人 userId,ownerUserIds 为可选的负责人 userId 字符串数组"}}, + }, + }) +} + +func recruitListParamDecls() []contract.ParamDecl { + return []contract.ParamDecl{ + {Name: "job-ids", Property: "param.jobIds", InterfaceType: "array"}, + {Name: "required-edu", Property: "param.requiredEdu", InterfaceType: "number"}, + {Name: "status", Property: "param.statusList", InterfaceType: "array", Enum: []string{"draft", "open", "invalid", "closed"}}, + {Name: "job-nature", Property: "param.jobNature", InterfaceType: "string"}, + {Name: "campus", Property: "param.campus", InterfaceType: "boolean"}, + {Name: "start-modified-time", Property: "param.startModifiedTime", InterfaceType: "string"}, + {Name: "end-modified-time", Property: "param.endModifiedTime", InterfaceType: "string"}, + {Name: "creator-user-ids", Property: "param.creatorUserIds", InterfaceType: "array"}, + {Name: "keyword", Property: "param.keyword", InterfaceType: "string"}, + {Name: "category", Property: "param.category", InterfaceType: "string"}, + {Name: "cursor", Property: "cursor", InterfaceType: "number"}, + {Name: "size", Property: "size", InterfaceType: "number"}, + } +} + +func recruitMCPInterface(tool string) *contract.InterfaceSpec { + return &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: recruitServerID, RPCName: tool}, + } +} + +func recruitSafetyRead() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func recruitSafetyCreate() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: "non_idempotent"} +} + +func transformRecruitStatuses(raw string) (any, error) { + values := strings.Split(raw, ",") + statuses := make([]int, 0, len(values)) + seen := make(map[int]bool, len(values)) + lookup := map[string]int{"draft": 0, "open": 1, "invalid": 2, "closed": 3} + for _, value := range values { + name := strings.ToLower(strings.TrimSpace(value)) + if name == "" { + continue + } + status, ok := lookup[name] + if !ok { + return nil, apperrors.NewValidation(fmt.Sprintf("--status 不支持 %q,可选 draft/open/invalid/closed", value)) + } + if !seen[status] { + statuses = append(statuses, status) + seen[status] = true + } + } + return statuses, nil +} + +func transformRecruitCursor(raw string) (any, error) { + value := strings.TrimSpace(raw) + cursor, err := strconv.ParseInt(value, 10, 64) + if err != nil || cursor < 0 { + return nil, apperrors.NewValidation("--cursor 必须是大于或等于 0 的整数") + } + return cursor, nil +} + +func validateRecruitList(cmd *cobra.Command, _ []string) error { + size, _ := cmd.Flags().GetInt("size") + if cmd.Flags().Changed("size") && (size < 1 || size > 100) { + return apperrors.NewValidation("--size 必须在 1 到 100 之间") + } + requiredEdu, _ := cmd.Flags().GetInt("required-edu") + if cmd.Flags().Changed("required-edu") && (requiredEdu < 1 || requiredEdu > 9) { + return apperrors.NewValidation("--required-edu 必须在 1 到 9 之间") + } + return nil +} + +func loadRecruitJobFile(path string) (any, error) { + data, err := os.ReadFile(strings.TrimSpace(path)) + if err != nil { + return nil, fmt.Errorf("读取职位 JSON 失败: %w", err) + } + var job map[string]any + if err := json.Unmarshal(data, &job); err != nil { + return nil, apperrors.NewValidation(fmt.Sprintf("职位文件不是有效的 JSON 对象: %v", err)) + } + if job == nil { + return nil, apperrors.NewValidation("职位 JSON 顶层必须是对象") + } + if err := validateRecruitJob(job); err != nil { + return nil, err + } + return job, nil +} + +func validateRecruitJob(job map[string]any) error { + for _, name := range []string{"name", "description", "jobNature", "requiredEdu", "extData", "creatorUserId"} { + value, ok := job[name] + if !ok || value == nil || (isString(value) && strings.TrimSpace(value.(string)) == "") { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 缺少必填字段 %s", name)) + } + } + for _, name := range []string{"name", "description", "jobNature"} { + if !isString(job[name]) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + if job["jobNature"].(string) != "FULL-TIME" { + return apperrors.NewValidation("职位 JSON 字段 jobNature 当前仅支持 FULL-TIME") + } + requiredEdu, ok := job["requiredEdu"].(float64) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是数字") + } + if requiredEdu < 1 || requiredEdu > 9 || requiredEdu != float64(int(requiredEdu)) { + return apperrors.NewValidation("职位 JSON 字段 requiredEdu 必须是 1 到 9 的整数") + } + + minSalary, hasMinSalary, err := optionalRecruitNumber(job, "minSalary") + if err != nil { + return err + } + maxSalary, hasMaxSalary, err := optionalRecruitNumber(job, "maxSalary") + if err != nil { + return err + } + if hasMinSalary && hasMaxSalary && minSalary > maxSalary { + return apperrors.NewValidation("职位 JSON 中 minSalary 不能大于 maxSalary") + } + + for _, name := range []string{"province", "city", "district", "category"} { + if value, exists := job[name]; exists && value != nil && !isString(value) { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是字符串", name)) + } + } + if value, exists := job["campus"]; exists && value != nil { + if _, ok := value.(bool); !ok { + return apperrors.NewValidation("职位 JSON 字段 campus 必须是布尔值") + } + } + if err := validateRecruitIdentityFields(job); err != nil { + return err + } + if err := validateRecruitAddress(job); err != nil { + return err + } + extData, ok := job["extData"].(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData 必须是对象") + } + if err := validateRecruitExtData(extData); err != nil { + return err + } + return nil +} + +func optionalRecruitNumber(values map[string]any, name string) (float64, bool, error) { + value, exists := values[name] + if !exists || value == nil { + return 0, false, nil + } + number, ok := value.(float64) + if !ok { + return 0, false, apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 %s 必须是数字", name)) + } + return number, true, nil +} + +func validateRecruitIdentityFields(job map[string]any) error { + if _, ok := job["creatorUserId"].(string); !ok { + return apperrors.NewValidation("职位 JSON 字段 creatorUserId 必须是非空字符串") + } + if value, exists := job["ownerUserIds"]; exists && value != nil { + ownerUserIDs, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 必须是字符串数组") + } + for _, owner := range ownerUserIDs { + ownerUserID, ok := owner.(string) + if !ok || strings.TrimSpace(ownerUserID) == "" { + return apperrors.NewValidation("职位 JSON 字段 ownerUserIds 只能包含非空字符串") + } + } + } + return nil +} + +func validateRecruitAddress(job map[string]any) error { + value, exists := job["address"] + if !exists || value == nil { + return nil + } + address, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 address 必须是对象") + } + for _, name := range []string{"name", "detail", "longitude", "latitude"} { + field, exists := address[name] + text, ok := field.(string) + if !exists || !ok || strings.TrimSpace(text) == "" { + return apperrors.NewValidation(fmt.Sprintf("职位 JSON 字段 address.%s 必须是非空字符串", name)) + } + } + return nil +} + +func validateRecruitExtData(extData map[string]any) error { + if value, exists := extData["headCount"]; exists && value != nil { + headCount, ok := value.(float64) + if !ok || headCount < 1 || headCount > 999 || headCount != float64(int(headCount)) { + return apperrors.NewValidation("职位 JSON 字段 extData.headCount 必须是 1 到 999 的整数") + } + } + if value, exists := extData["source"]; exists && value != nil && !isString(value) { + return apperrors.NewValidation("职位 JSON 字段 extData.source 必须是字符串") + } + if value, exists := extData["fullTimeExtData"]; exists && value != nil { + fullTime, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData 必须是对象") + } + if err := validateRecruitFullTimeExtData(fullTime); err != nil { + return err + } + } + if value, exists := extData["tags"]; exists && value != nil { + tags, ok := value.([]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 必须是数组") + } + for _, value := range tags { + tag, ok := value.(map[string]any) + if !ok { + return apperrors.NewValidation("职位 JSON 字段 extData.tags 只能包含对象") + } + name, ok := tag["name"].(string) + if !ok || strings.TrimSpace(name) == "" { + return apperrors.NewValidation("职位 JSON 字段 extData.tags[].name 必须是非空字符串") + } + } + } + return nil +} + +func validateRecruitFullTimeExtData(fullTime map[string]any) error { + salaryMonth, hasSalaryMonth, err := optionalRecruitNumber(fullTime, "salaryMonth") + if err != nil { + return err + } + if hasSalaryMonth && (salaryMonth < 12 || salaryMonth > 24 || salaryMonth != float64(int(salaryMonth))) { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.salaryMonth 必须是 12 到 24 的整数") + } + minExperience, hasMinExperience, err := optionalRecruitNumber(fullTime, "minJobExperience") + if err != nil { + return err + } + maxExperience, hasMaxExperience, err := optionalRecruitNumber(fullTime, "maxJobExperience") + if err != nil { + return err + } + if hasMinExperience && minExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.minJobExperience 不能小于 0") + } + if hasMaxExperience && maxExperience < 0 { + return apperrors.NewValidation("职位 JSON 字段 extData.fullTimeExtData.maxJobExperience 不能小于 0") + } + if hasMinExperience && hasMaxExperience && minExperience > maxExperience { + return apperrors.NewValidation("职位 JSON 中 minJobExperience 不能大于 maxJobExperience") + } + return nil +} + +func isString(value any) bool { + _, ok := value.(string) + return ok +} diff --git a/internal/helpers/recruit_test.go b/internal/helpers/recruit_test.go new file mode 100644 index 00000000..bb8879b4 --- /dev/null +++ b/internal/helpers/recruit_test.go @@ -0,0 +1,1050 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + stderrors "errors" + "io" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type recruitCapturedCall struct { + productID string + tool string + args map[string]any +} + +type recruitCaptureCaller struct { + productID string + tool string + args map[string]any + calls []recruitCapturedCall + err error + text string + dryRun bool +} + +func (c *recruitCaptureCaller) CallTool(_ context.Context, productID, tool string, args map[string]any) (*edition.ToolResult, error) { + c.productID = productID + c.tool = tool + c.args = args + c.calls = append(c.calls, recruitCapturedCall{productID: productID, tool: tool, args: args}) + if c.err != nil { + return nil, c.err + } + text := c.text + if text == "" { + text = `{}` + switch tool { + case recruitListJobsTool: + text = `{"jobs":[],"hasMore":false}` + case recruitCreateJobTool: + text = `{"jobId":"created-job"}` + } + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (c *recruitCaptureCaller) Format() string { return "json" } +func (c *recruitCaptureCaller) DryRun() bool { return c.dryRun } +func (c *recruitCaptureCaller) Fields() string { return "" } +func (c *recruitCaptureCaller) JQ() string { return "" } + +func withRecruitCaller(t *testing.T) *recruitCaptureCaller { + t.Helper() + caller := &recruitCaptureCaller{} + InitDepsForTest(t, caller) + deps.Out.w = io.Discard + return caller +} + +func prepareRecruitTestCommand(cmd *cobra.Command) *cobra.Command { + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return cmd +} + +func TestRecruitJobListWrapsQueryParameters(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{ + "--keyword", "Java", "--status", "open,draft", "--creator-user-ids", "u1,u2", + "--campus=false", "--required-edu", "9", "--cursor", "10", "--size", "30", + }) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitListJobsTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", caller.productID, caller.tool, recruitServerID, recruitListJobsTool) + } + if caller.args["cursor"] != int64(10) || caller.args["size"] != 30 { + t.Fatalf("pagination args = %#v", caller.args) + } + param, ok := caller.args["param"].(map[string]any) + if !ok { + t.Fatalf("param = %#v, want object", caller.args["param"]) + } + if param["keyword"] != "Java" || param["campus"] != false || param["requiredEdu"] != 9 { + t.Fatalf("param = %#v", param) + } + statuses, ok := param["statusList"].([]int) + if !ok || len(statuses) != 2 || statuses[0] != 1 || statuses[1] != 0 { + t.Fatalf("statusList = %#v", param["statusList"]) + } + creators, ok := param["creatorUserIds"].([]string) + if !ok || len(creators) != 2 || creators[0] != "u1" || creators[1] != "u2" { + t.Fatalf("creatorUserIds = %#v", param["creatorUserIds"]) + } +} + +func TestRecruitJobListCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", "9223372036854775807"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("cursor = %#v (%T), want max int64", got, got) + } + + for _, cursor := range []string{"not-a-cursor", "9223372036854775808"} { + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--cursor", cursor}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0 的整数") { + t.Fatalf("cursor %q error = %v", cursor, err) + } + } +} + +func TestRecruitJobListResponseCursorRoundTrip(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"success":true,"result":{"list":[],"hasMore":true,"nextCursor":9223372036854775807}}` + + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || envelope.Meta.Pagination == nil { + t.Fatalf("envelope = %#v", envelope) + } + meta := envelope.Meta + if got := meta.Pagination.NextToken; got != "9223372036854775807" { + t.Fatalf("next token = %q, want lossless max int64", got) + } + + caller.text = `{"jobs":[],"hasMore":false}` + cmd = prepareRecruitTestCommand(newRecruitJobListCommand()) + cmd.SetArgs([]string{"--cursor", meta.Pagination.NextToken}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if got := caller.args["cursor"]; got != int64(9223372036854775807) { + t.Fatalf("replayed cursor = %#v (%T), want max int64", got, got) + } +} + +func TestRecruitJobListDefaultsAndValidation(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.args["size"] != 20 { + t.Fatalf("size = %#v, want 20", caller.args["size"]) + } + if _, ok := caller.args["cursor"]; ok { + t.Fatalf("first page unexpectedly sent cursor: %#v", caller.args) + } + + badStatus := prepareRecruitTestCommand(newRecruitJobListCommand()) + badStatus.SetArgs([]string{"--status", "unknown"}) + if err := badStatus.Execute(); err == nil || !strings.Contains(err.Error(), "draft/open/invalid/closed") { + t.Fatalf("invalid status error = %v", err) + } + + badSize := prepareRecruitTestCommand(newRecruitJobListCommand()) + badSize.SetArgs([]string{"--size", "101"}) + if err := badSize.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 100") { + t.Fatalf("invalid size error = %v", err) + } + + for _, requiredEdu := range []string{"0", "10"} { + t.Run("required edu "+requiredEdu, func(t *testing.T) { + caller := withRecruitCaller(t) + invalid := prepareRecruitTestCommand(newRecruitJobListCommand()) + invalid.SetArgs([]string{"--required-edu", requiredEdu}) + if err := invalid.Execute(); err == nil || !strings.Contains(err.Error(), "1 到 9") { + t.Fatalf("required edu %s error = %v", requiredEdu, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid required edu = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestRecruitJobGetDispatchesJobID(t *testing.T) { + caller := withRecruitCaller(t) + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + cmd.SetArgs([]string{"--job-id", "job-1"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if caller.productID != recruitServerID || caller.tool != recruitGetJobTool || caller.args["jobId"] != "job-1" { + t.Fatalf("captured call = %s/%s %#v", caller.productID, caller.tool, caller.args) + } +} + +func writeRecruitJobFixture(t *testing.T) (string, map[string]any) { + t.Helper() + path := filepath.Join(t.TempDir(), "job.json") + job := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": 6, "minSalary": 20000, "maxSalary": 35000, + "creatorUserId": "creator-user-id", "ownerUserIds": []string{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": 1, + "fullTimeExtData": map[string]any{"salaryMonth": 12, "minJobExperience": 1, "maxJobExperience": 3}, + }, + } + data, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + return path, job +} + +func TestRecruitJobCreateRequiresConfirmationBeforeRemoteCall(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetIn(strings.NewReader("")) + cmd.SetArgs([]string{"--from", path}) + err := cmd.Execute() + var appErr *apperrors.Error + if !stderrors.As(err, &appErr) || appErr.Reason != "confirmation_required" { + t.Fatalf("create without confirmation error = %T %v, want confirmation_required", err, err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls before confirmation = %d: %#v, want none", len(caller.calls), caller.calls) + } +} + +func TestRecruitJobCreateCallsRemoteOnceWhenConfirmed(t *testing.T) { + caller := withRecruitCaller(t) + path, _ := writeRecruitJobFixture(t) + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls after confirmation = %d: %#v, want exactly one", len(caller.calls), caller.calls) + } + call := caller.calls[0] + if call.productID != recruitServerID || call.tool != recruitCreateJobTool { + t.Fatalf("dispatch = %s/%s, want %s/%s", call.productID, call.tool, recruitServerID, recruitCreateJobTool) + } + wantArgs := map[string]any{"atsAddJobParam": map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id-1", "owner-user-id-2"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, + }} + if !reflect.DeepEqual(call.args, wantArgs) { + t.Fatalf("args = %#v, want %#v", call.args, wantArgs) + } +} + +func TestRecruitCreateValidatesJobFile(t *testing.T) { + withRecruitCaller(t) + badPath := filepath.Join(t.TempDir(), "bad-job.json") + if err := os.WriteFile(badPath, []byte(`{"name":"缺字段"}`), 0o600); err != nil { + t.Fatal(err) + } + bad := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + bad.Flags().Bool("yes", false, "确认创建") + bad.SetArgs([]string{"--from", badPath, "--yes"}) + if err := bad.Execute(); err == nil || !strings.Contains(err.Error(), "description") { + t.Fatalf("missing field error = %v", err) + } +} + +func TestRecruitCreateRejectsInvalidCreatorBeforeRemoteCall(t *testing.T) { + for _, test := range []struct { + name string + creator string + }{ + {name: "missing", creator: ""}, + {name: "null", creator: `,"creatorUserId":null`}, + {name: "blank", creator: `,"creatorUserId":" "`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{}` + test.creator + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "creatorUserId") { + t.Fatalf("creator validation error = %v, want creatorUserId error", err) + } + if len(caller.calls) != 0 { + t.Fatalf("MCP calls with invalid creator = %d, want 0", len(caller.calls)) + } + }) + } +} + +func TestRecruitCreateAllowsOptionalOwners(t *testing.T) { + for _, test := range []struct { + name string + owner string + }{ + {name: "missing"}, + {name: "null", owner: `,"ownerUserIds":null`}, + {name: "empty array", owner: `,"ownerUserIds":[]`}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + path := filepath.Join(t.TempDir(), "job.json") + payload := `{"name":"Java 工程师","description":"服务端开发","jobNature":"FULL-TIME","requiredEdu":6,"extData":{},"creatorUserId":"creator-user-id"` + test.owner + `}` + if err := os.WriteFile(path, []byte(payload), 0o600); err != nil { + t.Fatal(err) + } + cmd := prepareRecruitTestCommand(newRecruitJobCreateCommand()) + cmd.Flags().Bool("yes", false, "确认创建") + cmd.SetArgs([]string{"--from", path, "--yes"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("optional owner create error = %v, want nil", err) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls with optional owner = %d, want 1", len(caller.calls)) + } + }) + } +} + +func TestRecruitValidationBranches(t *testing.T) { + statuses, err := transformRecruitStatuses(" open, ,open,closed ") + if err != nil || !reflect.DeepEqual(statuses, []int{1, 3}) { + t.Fatalf("statuses = %#v, err = %v", statuses, err) + } + + negativeCursor := prepareRecruitTestCommand(newRecruitJobListCommand()) + negativeCursor.SetArgs([]string{"--cursor", "-1"}) + if err := negativeCursor.Execute(); err == nil || !strings.Contains(err.Error(), "大于或等于 0") { + t.Fatalf("negative cursor error = %v", err) + } + + nullPath := filepath.Join(t.TempDir(), "null.json") + if err := os.WriteFile(nullPath, []byte("null"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(nullPath); err == nil || !strings.Contains(err.Error(), "顶层必须是对象") { + t.Fatalf("null job error = %v", err) + } + if _, err := loadRecruitJobFile(filepath.Join(t.TempDir(), "missing.json")); err == nil || !strings.Contains(err.Error(), "读取职位 JSON 失败") { + t.Fatalf("missing job file error = %v", err) + } + invalidPath := filepath.Join(t.TempDir(), "invalid.json") + if err := os.WriteFile(invalidPath, []byte("{"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := loadRecruitJobFile(invalidPath); err == nil || !strings.Contains(err.Error(), "不是有效的 JSON 对象") { + t.Fatalf("invalid job JSON error = %v", err) + } + + base := map[string]any{ + "name": "Java 工程师", "description": "服务端开发", "jobNature": "FULL-TIME", + "requiredEdu": float64(6), "minSalary": float64(20000), "maxSalary": float64(35000), + "creatorUserId": "creator-user-id", "ownerUserIds": []any{"owner-user-id"}, + "extData": map[string]any{ + "headCount": float64(1), + "fullTimeExtData": map[string]any{"salaryMonth": float64(12), "minJobExperience": float64(1), "maxJobExperience": float64(3)}, + }, + } + for _, test := range []struct { + name string + mutate func(map[string]any) + want string + }{ + {name: "string type", mutate: func(job map[string]any) { job["name"] = 1 }, want: "name 必须是字符串"}, + {name: "number type", mutate: func(job map[string]any) { job["requiredEdu"] = "本科" }, want: "requiredEdu 必须是数字"}, + {name: "missing ext data", mutate: func(job map[string]any) { delete(job, "extData") }, want: "缺少必填字段 extData"}, + {name: "missing creator", mutate: func(job map[string]any) { delete(job, "creatorUserId") }, want: "缺少必填字段 creatorUserId"}, + {name: "null creator", mutate: func(job map[string]any) { job["creatorUserId"] = nil }, want: "缺少必填字段 creatorUserId"}, + {name: "ext data type", mutate: func(job map[string]any) { job["extData"] = "invalid" }, want: "extData 必须是对象"}, + {name: "job nature enum", mutate: func(job map[string]any) { job["jobNature"] = "FULL_TIME" }, want: "仅支持 FULL-TIME"}, + {name: "education range", mutate: func(job map[string]any) { job["requiredEdu"] = float64(10) }, want: "1 到 9 的整数"}, + {name: "salary range", mutate: func(job map[string]any) { job["minSalary"] = float64(40000) }, want: "minSalary 不能大于 maxSalary"}, + {name: "min salary type", mutate: func(job map[string]any) { job["minSalary"] = "20000" }, want: "minSalary 必须是数字"}, + {name: "salary type", mutate: func(job map[string]any) { job["maxSalary"] = "35000" }, want: "maxSalary 必须是数字"}, + {name: "creator type", mutate: func(job map[string]any) { job["creatorUserId"] = " " }, want: "缺少必填字段 creatorUserId"}, + {name: "creator non-string", mutate: func(job map[string]any) { job["creatorUserId"] = float64(123) }, want: "creatorUserId 必须是非空字符串"}, + {name: "owners type", mutate: func(job map[string]any) { job["ownerUserIds"] = "owner-user-id" }, want: "ownerUserIds 必须是字符串数组"}, + {name: "owner item", mutate: func(job map[string]any) { job["ownerUserIds"] = []any{""} }, want: "ownerUserIds 只能包含非空字符串"}, + {name: "campus type", mutate: func(job map[string]any) { job["campus"] = "false" }, want: "campus 必须是布尔值"}, + {name: "region type", mutate: func(job map[string]any) { job["city"] = 330100 }, want: "city 必须是字符串"}, + {name: "address type", mutate: func(job map[string]any) { job["address"] = "杭州" }, want: "address 必须是对象"}, + {name: "address required field", mutate: func(job map[string]any) { + job["address"] = map[string]any{"name": "亲橙Park", "detail": "杭州市余杭区", "longitude": "120.0"} + }, want: "address.latitude 必须是非空字符串"}, + {name: "head count range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"headCount": float64(0)} + }, want: "headCount 必须是 1 到 999 的整数"}, + {name: "source type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"source": true} + }, want: "extData.source 必须是字符串"}, + {name: "full time ext data type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": "invalid"} + }, want: "fullTimeExtData 必须是对象"}, + {name: "salary month range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": float64(25)}} + }, want: "salaryMonth 必须是 12 到 24 的整数"}, + {name: "salary month type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"salaryMonth": "12"}} + }, want: "salaryMonth 必须是数字"}, + {name: "min experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": "1"}} + }, want: "minJobExperience 必须是数字"}, + {name: "max experience type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": "3"}} + }, want: "maxJobExperience 必须是数字"}, + {name: "negative min experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(-1)}} + }, want: "minJobExperience 不能小于 0"}, + {name: "negative max experience", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"maxJobExperience": float64(-1)}} + }, want: "maxJobExperience 不能小于 0"}, + {name: "experience range", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"fullTimeExtData": map[string]any{"minJobExperience": float64(5), "maxJobExperience": float64(3)}} + }, want: "minJobExperience 不能大于 maxJobExperience"}, + {name: "tags type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": "五险一金"} + }, want: "extData.tags 必须是数组"}, + {name: "tag item type", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{"五险一金"}} + }, want: "extData.tags 只能包含对象"}, + {name: "tag name", mutate: func(job map[string]any) { + job["extData"] = map[string]any{"tags": []any{map[string]any{"name": ""}}} + }, want: "tags[].name 必须是非空字符串"}, + } { + t.Run(test.name, func(t *testing.T) { + job := make(map[string]any, len(base)) + for key, value := range base { + job[key] = value + } + test.mutate(job) + if err := validateRecruitJob(job); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validation error = %v, want %q", err, test.want) + } + }) + } + + withoutSalary := make(map[string]any, len(base)) + for key, value := range base { + withoutSalary[key] = value + } + delete(withoutSalary, "minSalary") + delete(withoutSalary, "maxSalary") + if err := validateRecruitJob(withoutSalary); err != nil { + t.Fatalf("optional salary validation error = %v, want nil", err) + } + + completeAddress := make(map[string]any, len(base)+1) + for key, value := range base { + completeAddress[key] = value + } + completeAddress["address"] = map[string]any{ + "name": "亲橙Park", "detail": "浙江省杭州市余杭区", "longitude": "120.008822", "latitude": "30.270739", + } + completeAddress["campus"] = false + completeAddress["province"] = "330000" + completeAddress["city"] = "330100" + completeAddress["district"] = "330110" + if err := validateRecruitJob(completeAddress); err != nil { + t.Fatalf("complete job validation error = %v, want nil", err) + } +} + +func TestRecruitListResultData(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing hasMore", data: map[string]any{}, want: "缺少布尔字段 hasMore"}, + {name: "invalid cursor", data: map[string]any{"hasMore": true, "nextCursor": true}, want: "必须是字符串或数字"}, + {name: "missing cursor", data: map[string]any{"hasMore": true}, want: "缺少 nextCursor"}, + {name: "exponent cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1e3")}, want: "非负十进制 int64"}, + {name: "decimal cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("1.5")}, want: "非负十进制 int64"}, + {name: "overflow cursor", data: map[string]any{"hasMore": true, "nextCursor": json.Number("9223372036854775808")}, want: "非负十进制 int64"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := recruitListResultData(test.data); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + }) + } + + data, meta, err := recruitListResultData(map[string]any{ + "jobs": []any{map[string]any{"jobId": "job-1"}}, "hasMore": true, "nextCursor": float64(12), + }) + if err != nil { + t.Fatal(err) + } + if meta.Count == nil || *meta.Count != 1 || meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "12" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 1 { + t.Fatalf("meta = %#v", meta) + } + clean := data.(map[string]any) + if _, exists := clean["hasMore"]; exists || clean["jobs"] == nil { + t.Fatalf("clean data = %#v", clean) + } + + _, normalizedCursor, err := recruitListResultData(map[string]any{ + "jobs": []any{}, "hasMore": true, "nextCursor": "00012", + }) + if err != nil || normalizedCursor.Pagination.NextToken != "12" { + t.Fatalf("normalized cursor meta = %#v, err = %v", normalizedCursor, err) + } + + _, terminal, err := recruitListResultData(map[string]any{"jobs": []any{}, "hasMore": false, "nextCursor": "ignored"}) + if err != nil || !terminal.Pagination.EndpointExhausted || terminal.Pagination.NextToken != "" { + t.Fatalf("terminal meta = %#v, err = %v", terminal, err) + } + + normalized, normalizedMeta, err := recruitListResultData(map[string]any{ + "list": []any{map[string]any{"jobId": "job-2", "name": "测试工程师"}}, + "hasMore": false, + "nextCursor": nil, + "totalCount": float64(1), + "success": true, + "result": map[string]any{"source": "business-payload"}, + }) + if err != nil { + t.Fatal(err) + } + normalizedData := normalized.(map[string]any) + jobs, ok := normalizedData["jobs"].([]any) + if !ok || len(jobs) != 1 || normalizedData["totalCount"] != float64(1) { + t.Fatalf("normalized data = %#v", normalizedData) + } + if _, exists := normalizedData["list"]; exists { + t.Fatalf("normalized data retained Connector list field: %#v", normalizedData) + } + if normalizedData["success"] != true || !reflect.DeepEqual( + normalizedData["result"], map[string]any{"source": "business-payload"}, + ) { + t.Fatalf("business success/result fields were unwrapped: %#v", normalizedData) + } + if normalizedMeta.Count == nil || *normalizedMeta.Count != 1 || !normalizedMeta.Pagination.EndpointExhausted { + t.Fatalf("normalized meta = %#v", normalizedMeta) + } +} + +func TestRecruitListResultDataAcceptsMinimalTerminalPage(t *testing.T) { + data, meta, err := recruitListResultData(map[string]any{"hasMore": false}) + if err != nil { + t.Fatal(err) + } + clean, ok := data.(map[string]any) + if !ok || len(clean) != 0 { + t.Fatalf("data = %#v, want empty object", data) + } + if meta == nil || meta.Pagination == nil || !meta.Pagination.EndpointExhausted || + meta.Pagination.NextToken != "" || meta.Pagination.Pages != 1 || meta.Pagination.Items != 0 { + t.Fatalf("meta = %#v, want exhausted terminal page", meta) + } + if meta.Count != nil { + t.Fatalf("count = %#v, want nil when jobs is absent", meta.Count) + } + + caller := withRecruitCaller(t) + caller.text = `{"success":true,"result":{"hasMore":false}}` + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeSuccess || envelope.Meta == nil || + envelope.Meta.Pagination == nil || !envelope.Meta.Pagination.EndpointExhausted { + t.Fatalf("envelope = %#v, want successful exhausted terminal page", envelope) + } +} + +func TestRecruitResultCallPropagatesMCPError(t *testing.T) { + caller := withRecruitCaller(t) + caller.err = stderrors.New("transport failed") + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + if _, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}); err == nil { + t.Fatal("expected MCP error") + } +} + +func TestRecruitMCPDataDecoderIsLosslessAndStrict(t *testing.T) { + for _, test := range []struct { + name string + text string + wantErr string + }{ + {name: "empty", text: " "}, + {name: "invalid JSON", text: "{", wantErr: "解析 list_jobs 返回失败"}, + {name: "multiple values", text: `{} {}`, wantErr: "存在多个 JSON 值"}, + {name: "invalid trailing value", text: `{} {`, wantErr: "解析 list_jobs 返回失败"}, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if test.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("error = %v, want %q", err, test.wantErr) + } + return + } + if err != nil || len(data.(map[string]any)) != 0 { + t.Fatalf("empty response data = %#v, err = %v", data, err) + } + }) + } + + caller := withRecruitCaller(t) + caller.text = `{"nextCursor":9223372036854775807}` + data, err := callRecruitMCPToolData(context.Background(), recruitListJobsTool, map[string]any{}) + if err != nil || data.(map[string]any)["nextCursor"] != json.Number("9223372036854775807") { + t.Fatalf("lossless recruit number data = %#v, err = %v", data, err) + } +} + +func TestRecruitResultCallUnwrapsConnectorEnvelopeForGetAndCreate(t *testing.T) { + for _, test := range []struct { + name string + tool string + text string + want map[string]any + }{ + { + name: "get detail", + tool: recruitGetJobTool, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + want: map[string]any{"jobId": "job-detail", "name": "Java 工程师", "status": json.Number("1")}, + }, + { + name: "create job", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + want: map[string]any{"jobId": "job-created"}, + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + data, err := callRecruitMCPToolData(context.Background(), test.tool, map[string]any{}) + if err != nil { + t.Fatal(err) + } + clean, err := recruitBusinessResultData(data, test.tool) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(clean, test.want) { + t.Fatalf("normalized data = %#v, want %#v", clean, test.want) + } + if _, hasSuccess := clean["success"]; hasSuccess { + t.Fatalf("normalized data retained Connector envelope: %#v", clean) + } + if _, hasResult := clean["result"]; hasResult { + t.Fatalf("normalized data retained nested result: %#v", clean) + } + }) + } +} + +func TestRecruitCommandsPublishUnwrappedConnectorResult(t *testing.T) { + for _, test := range []struct { + name string + leaf func() *cobra.Command + args func(*testing.T) []string + text string + wantID string + }{ + { + name: "get detail", + leaf: newRecruitJobGetCommand, + args: func(*testing.T) []string { return []string{"--job-id", "job-detail"} }, + text: `{"success":true,"result":{"jobId":"job-detail","name":"Java 工程师","status":1}}`, + wantID: "job-detail", + }, + { + name: "create job", + leaf: newRecruitJobCreateCommand, + args: func(t *testing.T) []string { + path, _ := writeRecruitJobFixture(t) + return []string{"--from", path, "--yes"} + }, + text: `{"success":true,"result":{"jobId":"job-created"}}`, + wantID: "job-created", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + root := &cobra.Command{Use: "dws", SilenceErrors: true, SilenceUsage: true} + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.PersistentFlags().String("format", "json", "") + root.PersistentFlags().String("fields", "", "") + root.PersistentFlags().String("jq", "", "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().Bool("yes", false, "") + root.PersistentPostRunE = func(cmd *cobra.Command, _ []string) error { + _, _, err := output.EmitStoredResult(cmd) + return err + } + leaf := test.leaf() + root.AddCommand(leaf) + var stdout bytes.Buffer + root.SetOut(&stdout) + root.SetArgs(append([]string{leaf.Name()}, test.args(t)...)) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output %q: %v", stdout.String(), err) + } + if envelope.Data["jobId"] != test.wantID { + t.Fatalf("data = %#v, want top-level jobId %q", envelope.Data, test.wantID) + } + if _, exists := envelope.Data["success"]; exists { + t.Fatalf("public data retained Connector success: %#v", envelope.Data) + } + if _, exists := envelope.Data["result"]; exists { + t.Fatalf("public data retained Connector result: %#v", envelope.Data) + } + }) + } +} + +func TestRecruitBusinessResultDataRejectsInvalidConnectorEnvelope(t *testing.T) { + for _, test := range []struct { + name string + data any + want string + }{ + {name: "not object", data: []any{}, want: "必须是 JSON 对象"}, + {name: "missing result", data: map[string]any{"success": true}, want: "同时包含 success 和 result"}, + {name: "missing success", data: map[string]any{"result": map[string]any{}}, want: "同时包含 success 和 result"}, + {name: "invalid success", data: map[string]any{"success": "true", "result": map[string]any{}}, want: "success 必须是布尔值"}, + {name: "business failure", data: map[string]any{"success": false, "message": "职位不存在", "result": map[string]any{}}, want: "职位不存在"}, + {name: "business failure without message", data: map[string]any{"success": false, "result": map[string]any{}}, want: "Connector 返回 success=false"}, + {name: "invalid result", data: map[string]any{"success": true, "result": nil}, want: "result 必须是 JSON 对象"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := recruitBusinessResultData(test.data, recruitGetJobTool) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want %q", err, test.want) + } + var businessFailure *recruitBusinessFailure + isBusinessFailure := stderrors.As(err, &businessFailure) + wantBusinessFailure := strings.HasPrefix(test.name, "business failure") + if isBusinessFailure != wantBusinessFailure { + t.Fatalf("business failure classification = %t, want %t", isBusinessFailure, wantBusinessFailure) + } + }) + } +} + +func TestRecruitResultCallClassifiesBusinessAndMalformedFailures(t *testing.T) { + for _, test := range []struct { + name string + text string + wantSubtype string + wantMessage string + wantHint bool + }{ + { + name: "business failure", + text: `{"success":false,"message":"职位不存在","result":{}}`, + wantMessage: "职位不存在", + }, + { + name: "malformed envelope", + text: `{"success":true}`, + wantSubtype: "invalid_response", + wantMessage: "同时包含 success 和 result", + wantHint: true, + }, + } { + t.Run(test.name, func(t *testing.T) { + var payload any + if err := json.Unmarshal([]byte(test.text), &payload); err != nil { + t.Fatal(err) + } + _, responseErr := recruitBusinessResultData(payload, recruitGetJobTool) + if responseErr == nil { + t.Fatal("expected response error") + } + result := recruitResponseFailure(responseErr) + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != test.wantSubtype || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v", envelope.Error) + } + if (envelope.Error.Hint != "") != test.wantHint { + t.Fatalf("hint = %q, want present %t", envelope.Error.Hint, test.wantHint) + } + }) + } +} + +func TestRecruitResultCallRejectsInconsistentPagination(t *testing.T) { + for _, response := range []string{ + `{"jobs":[],"hasMore":true}`, + `{"jobs":[],"hasMore":true,"nextCursor":1e3}`, + `{"jobs":[],"hasMore":true,"nextCursor":1.5}`, + `{"jobs":[],"hasMore":true,"nextCursor":9223372036854775808}`, + } { + t.Run(response, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = response + cmd := prepareRecruitTestCommand(newRecruitJobListCommand()) + result, err := recruitResultCall(cmd, recruitListJobsTool, map[string]any{"size": 20}) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" { + t.Fatalf("envelope = %#v, want invalid_response failure", envelope) + } + }) + } +} + +func TestRecruitResultCallRejectsInvalidJobResults(t *testing.T) { + for _, test := range []struct { + name string + tool string + args map[string]any + text string + wantMessage string + }{ + { + name: "create empty response", + tool: recruitCreateJobTool, + text: " ", + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty object", + tool: recruitCreateJobTool, + text: `{}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create envelope missing job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create empty job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":" "}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "create non-string job id", + tool: recruitCreateJobTool, + text: `{"success":true,"result":{"jobId":123}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get missing job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"name":"Java 工程师"}}`, + wantMessage: "缺少非空字符串字段 jobId", + }, + { + name: "get request missing job id", + tool: recruitGetJobTool, + args: map[string]any{}, + text: `{"success":true,"result":{"jobId":"job-returned"}}`, + wantMessage: "请求缺少非空字符串字段 jobId", + }, + { + name: "get mismatched job id", + tool: recruitGetJobTool, + args: map[string]any{"jobId": "job-requested"}, + text: `{"success":true,"result":{"jobId":"job-other"}}`, + wantMessage: "与请求的 jobId", + }, + } { + t.Run(test.name, func(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = test.text + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), test.tool, test.args) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil { + t.Fatalf("envelope = %#v, want failure", envelope) + } + if envelope.Error.Type != "api" || envelope.Error.Subtype != "invalid_response" || + !strings.Contains(envelope.Error.Message, test.wantMessage) { + t.Fatalf("error = %#v, want invalid_response containing %q", envelope.Error, test.wantMessage) + } + if len(caller.calls) != 1 { + t.Fatalf("MCP calls = %d, want exactly one", len(caller.calls)) + } + }) + } +} + +func TestRecruitResultCallRejectsToolWithoutResultValidator(t *testing.T) { + caller := withRecruitCaller(t) + caller.text = `{"jobId":"job-created"}` + result, err := recruitResultCall(prepareRecruitTestCommand(&cobra.Command{}), "future_write_tool", nil) + if err != nil { + t.Fatal(err) + } + envelope, err := output.EnvelopeFromResult(result) + if err != nil { + t.Fatal(err) + } + if envelope.Outcome != output.OutcomeFailure || envelope.Error == nil || + envelope.Error.Subtype != "invalid_response" || !strings.Contains(envelope.Error.Message, "不支持校验") { + t.Fatalf("envelope = %#v, want unsupported validator failure", envelope) + } +} + +func TestRecruitResultCallDryRun(t *testing.T) { + caller := withRecruitCaller(t) + caller.dryRun = true + cmd := prepareRecruitTestCommand(newRecruitJobGetCommand()) + result, err := recruitResultCall(cmd, recruitGetJobTool, map[string]any{"jobId": "job-1"}) + if err != nil { + t.Fatal(err) + } + if result.Outcome() != output.OutcomeSuccess || len(caller.calls) != 0 { + t.Fatalf("outcome = %s, calls = %d", result.Outcome(), len(caller.calls)) + } +} + +func TestRecruitCommandTree(t *testing.T) { + root := newRecruitCommand() + for _, path := range [][]string{{"job", "list"}, {"job", "get"}, {"job", "create"}} { + cmd, _, err := root.Find(path) + if err != nil || cmd == root || cmd.Name() != path[len(path)-1] { + t.Fatalf("Find(%v) = %v, %v", path, cmd, err) + } + } +} + +func TestRecruitResultAndPaginationContractsReachContractFinal(t *testing.T) { + root := newRecruitCommand() + tests := []struct { + path []string + canonical string + resultField string + pagination bool + }{ + {path: []string{"job", "list"}, canonical: "recruit.list_jobs", resultField: "jobs", pagination: true}, + {path: []string{"job", "get"}, canonical: "recruit.get_job_detail", resultField: "jobId"}, + {path: []string{"job", "create"}, canonical: "recruit.create_job", resultField: "jobId"}, + } + for _, test := range tests { + t.Run(test.canonical, func(t *testing.T) { + leaf, _, err := root.Find(test.path) + if err != nil { + t.Fatal(err) + } + final, ok := contractfinal.RuntimeContractFinal(leaf) + if !ok || final.Identity == nil || final.Identity.CanonicalPath != test.canonical { + t.Fatalf("final identity = %#v, found = %v", final.Identity, ok) + } + if final.Result == nil { + t.Fatal("final Result is nil") + } + wantOutcomes := []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure} + if !reflect.DeepEqual(final.Result.Outcomes, wantOutcomes) { + t.Fatalf("outcomes = %#v, want %#v", final.Result.Outcomes, wantOutcomes) + } + var schema struct { + Type string `json:"type"` + Properties map[string]json.RawMessage `json:"properties"` + } + if err := json.Unmarshal(final.Result.DataSchema, &schema); err != nil { + t.Fatalf("unmarshal result data_schema: %v", err) + } + if schema.Type != "object" || len(schema.Properties[test.resultField]) == 0 { + t.Fatalf("result data_schema = %s, want object property %q", final.Result.DataSchema, test.resultField) + } + if test.pagination { + if final.Pagination == nil || final.Pagination.Kind != contract.PaginationKindCursor || + final.Pagination.CursorParameter != "cursor" || final.Pagination.MetaPath != contract.PaginationMetaPath { + t.Fatalf("pagination = %#v, want cursor contract", final.Pagination) + } + } else if final.Pagination != nil { + t.Fatalf("pagination = %#v, want nil", final.Pagination) + } + }) + } +} diff --git a/internal/helpers/register_products_test.go b/internal/helpers/register_products_test.go index 7e89a565..16933d43 100644 --- a/internal/helpers/register_products_test.go +++ b/internal/helpers/register_products_test.go @@ -31,7 +31,7 @@ func TestCrossPlatformCoveragePublicProductCommandsBuildCompleteUniqueTrees(t *t for _, want := range []string{ "agoal", "aisearch", "aitable", "attendance", "calendar", "chat", "contact", "devdoc", "ding", "doc", "drive", "live", "mail", - "markdown", "minutes", "oa", "report", "sheet", "todo", "wiki", "whiteboard", + "markdown", "minutes", "oa", "recruit", "report", "sheet", "todo", "wiki", "whiteboard", } { if !seenProducts[want] { t.Errorf("public product %q was not registered", want) diff --git a/internal/helpers/register_recruit.go b/internal/helpers/register_recruit.go new file mode 100644 index 00000000..3ba9ceb2 --- /dev/null +++ b/internal/helpers/register_recruit.go @@ -0,0 +1,11 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package helpers + +// 招聘是开源库显式维护的公开命令,不依赖生成式产品注册表。 +func init() { + RegisterPublic(func() Handler { + return wukongHandler{name: "recruit", buildFn: newRecruitCommand} + }) +} diff --git a/internal/helpers/sheet.go b/internal/helpers/sheet.go index 01246531..682c4f4f 100644 --- a/internal/helpers/sheet.go +++ b/internal/helpers/sheet.go @@ -26,10 +26,11 @@ func newSheetCommand() *cobra.Command { contract.RegisterProductDecl(contract.ProductDecl{ ID: "sheet", Selection: contract.ProductSelectionDecl{ - AgentSummary: "导入本地 Excel,或创建、读取、编辑和导出钉钉在线电子表格(axls),并管理工作表、区域、筛选、图表、图片与格式。", + AgentSummary: "导入本地 Excel,或创建、读取、编辑、导出和审计钉钉在线电子表格(axls),并管理工作表、区域、历史 revision、筛选、图表、图片与格式。", UseWhen: []string{ "用户要处理钉钉在线电子表格中的工作表、单元格、范围、筛选、图表、图片或格式时", "用户要把本地 xlsx/xls 转换为新的钉钉在线电子表格时", + "用户要查询工作簿当前 revision 或复核两个 revision 之间的 changeset 时", }, AvoidWhen: []string{ "目标是 AI 表格 Base 的结构化记录或钉钉文档正文时不要使用 sheet", @@ -110,6 +111,8 @@ func newSheetCommand() *cobra.Command { dws sheet chart create 创建浮动图表 dws sheet chart update 更新浮动图表 dws sheet chart delete 删除浮动图表 + dws sheet revision-get 获取工作簿当前 revision + dws sheet changeset-get 获取工作簿 revision 区间内的 changeset dws sheet export 导出表格为 xlsx(异步任务一站式:提交→轮询→可选下载) dws sheet export-csv 导出单个工作表为纯 CSV(同步,可落盘) dws sheet import 导入 xlsx/xls 为在线电子表格 @@ -169,6 +172,7 @@ func newSheetCommand() *cobra.Command { templateCmd := newSheetTemplateCmd() tableCmds := newTableCmds() pivotTableCmd := newPivotTableCmd() + revisionCmds := newSheetRevisionCmds() batchUpdateCmd := newBatchUpdateCmd() DeclareLeafMetadata(batchUpdateCmd, LeafSpec{ @@ -244,6 +248,7 @@ func newSheetCommand() *cobra.Command { standaloneCmds = append(standaloneCmds, mediaCmds...) standaloneCmds = append(standaloneCmds, floatImageCmds...) standaloneCmds = append(standaloneCmds, tableCmds...) + standaloneCmds = append(standaloneCmds, revisionCmds...) standaloneCmds = append(standaloneCmds, exportCmd, exportCsvCmd, importCmd, batchUpdateCmd, createWithDataCmd) // Register cross-product aliases diff --git a/internal/helpers/sheet_comment.go b/internal/helpers/sheet_comment.go index c0b62612..b16ddf33 100644 --- a/internal/helpers/sheet_comment.go +++ b/internal/helpers/sheet_comment.go @@ -1,6 +1,7 @@ package helpers import ( + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/spf13/cobra" ) @@ -154,6 +155,9 @@ func newSheetCommentCmd() *cobra.Command { "replyCommentKey": mustGetFlag(cmd, "comment-key"), } if v, _ := cmd.Flags().GetBool("emoji"); v { + if err := commentreaction.Validate(mustGetFlag(cmd, "content")); err != nil { + return err + } toolArgs["emoji"] = true } if v, _ := cmd.Flags().GetString("mention"); v != "" { diff --git a/internal/helpers/sheet_ports_coverage_test.go b/internal/helpers/sheet_ports_coverage_test.go index c8aa0fc5..7ce43f29 100644 --- a/internal/helpers/sheet_ports_coverage_test.go +++ b/internal/helpers/sheet_ports_coverage_test.go @@ -93,7 +93,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) { "comment", "reply", "--node", "node-1", "--comment-key", "ck-1", - "--content", "heart", + "--content", "比心", "--emoji", "--mention", "uid1", }, @@ -102,7 +102,7 @@ func TestCrossPlatformCoverageSheetCommentCommands(t *testing.T) { toolName: "reply_comment", args: map[string]any{ "nodeId": "node-1", - "content": "heart", + "content": "比心", "replyCommentKey": "ck-1", "emoji": true, "mentionedUserIds": []string{"uid1"}, diff --git a/internal/helpers/sheet_revision.go b/internal/helpers/sheet_revision.go new file mode 100644 index 00000000..04f1cb16 --- /dev/null +++ b/internal/helpers/sheet_revision.go @@ -0,0 +1,783 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "encoding/json" + "fmt" + "io" + "reflect" + "sort" + "strconv" + "strings" + "sync" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli" + "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/output" +) + +const ( + sheetRevisionGetRemoteTool = "get_sheet_revision" + sheetChangesetGetRemoteTool = "get_sheet_changeset" + sheetChangesetMaxSpan = int64(20) + sheetChangesetJSONMaxBytes = 2 * 1024 * 1024 +) + +var sheetRevisionResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"工作簿当前持久化 Delta revision", + "properties":{ + "success":{"type":"boolean","description":"服务端业务调用是否成功"}, + "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"}, + "revision":{"type":"number","description":"当前工作簿 revision;空工作簿为 0"} + }, + "required":["success","logId","revision"], + "additionalProperties":true + }`), +} + +var sheetChangesetResult = &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{ + "type":"object", + "description":"工作簿 revision 区间内连续、按 revision 升序排列的 V2 前向语义 changeset", + "properties":{ + "success":{"type":"boolean","description":"服务端业务调用是否成功"}, + "logId":{"type":"string","description":"服务端请求追踪 ID,可用于问题排查和反馈"}, + "schemaVersion":{"type":"number","description":"changeset 业务响应的语义版本;当前固定为 2","enum":[2]}, + "changeSemantics":{"type":"string","description":"变更详情只描述当时提交的前向效果,不提供统一 old/current 值;当前固定为 FORWARD_ONLY","enum":["FORWARD_ONLY"]}, + "latestRevision":{"type":"number","description":"请求开始时观测并固定的工作簿最新 revision"}, + "startRevision":{"type":"number","description":"查询基线 revision;结果不包含该 revision"}, + "endRevision":{"type":"number","description":"本次实际查询的结束 revision;结果包含该 revision"}, + "summary":{ + "type":"object", + "description":"本次区间内语义 change 的完整性和影响范围汇总", + "properties":{ + "changeCount":{"type":"number","description":"全部 changesets 中 change 对象的总数;STATE_RESET 不计作普通 change"}, + "completeChangeCount":{"type":"number","description":"detailsStatus 为 COMPLETE 的 change 数量"}, + "partialChangeCount":{"type":"number","description":"detailsStatus 为 PARTIAL 的 change 数量"}, + "unsupportedChangeCount":{"type":"number","description":"type 为 UNSUPPORTED_CHANGE 的 change 数量"}, + "containsStateReset":{"type":"boolean","description":"区间内是否包含 STATE_RESET 事件"}, + "containsIncompleteChanges":{"type":"boolean","description":"区间内是否包含 PARTIAL、UNAVAILABLE 或 UNSUPPORTED_CHANGE"}, + "affectedSheets":{ + "type":"array", + "description":"由 AFFECTED 或 DESTINATION target 去重排序得到的工作表与 A1 范围摘要;SOURCE 不计入", + "items":{ + "type":"object", + "properties":{ + "sheetId":{"type":"string","description":"受影响工作表的稳定 ID"}, + "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称;缺失时不得由 sheetId 猜测"}, + "ranges":{"type":"array","description":"去重排序后的 1-based A1 范围;工作表级变更为空数组","items":{"type":"string","description":"1-based A1 范围;整行或整列可写为 1:3 或 A:C"}} + }, + "required":["sheetId","ranges"], + "additionalProperties":true + } + } + }, + "required":["changeCount","completeChangeCount","partialChangeCount","unsupportedChangeCount","containsStateReset","containsIncompleteChanges","affectedSheets"], + "additionalProperties":true + }, + "changesets":{ + "type":"array", + "description":"区间 (startRevision, endRevision] 内按 revision 升序排列的完整事件", + "items":{ + "type":"object", + "properties":{ + "revision":{"type":"number","description":"该 changeset 对应的 Delta revision"}, + "createTime":{"type":"string","description":"该 revision 的创建时间"}, + "isSelfEdit":{"type":"boolean","description":"该 Delta 是否由当前请求用户提交;false 不能用于识别具体编辑者"}, + "eventType":{"type":"string","description":"事件类型;EDIT 为普通前向编辑,UNDO 为撤销提交产生的前向效果,STATE_RESET 为全量状态替换点","enum":["EDIT","UNDO","STATE_RESET"]}, + "detailsStatus":{"type":"string","description":"该事件内所有 change 的最差详情完整度;COMPLETE、PARTIAL 或 UNAVAILABLE","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]}, + "reset":{ + "type":"object", + "description":"STATE_RESET 的状态替换信息;EDIT 和 UNDO 不返回该对象", + "properties":{ + "type":{"type":"string","description":"状态替换原因类型","enum":["ROLLBACK","OVERWRITE","UPGRADE","TEMPLATE","PRETTIFY","UNKNOWN_RESET"]}, + "targetRevision":{"type":"number","description":"targetStatus 为 KNOWN 时的目标 revision;0 是合法空基线"}, + "targetStatus":{"type":"string","description":"目标 revision 状态;KNOWN 表示已返回,NOT_APPLICABLE 表示该 reset 没有目标,UNAVAILABLE 表示应有目标但无法确认","enum":["KNOWN","NOT_APPLICABLE","UNAVAILABLE"]} + }, + "required":["type","targetStatus"], + "additionalProperties":true + }, + "changes":{ + "type":"array", + "description":"该 revision 内按提交顺序排列的前向语义 change;STATE_RESET 固定为空数组", + "items":{ + "type":"object", + "properties":{ + "type":{"type":"string","description":"Agent 可解释的前向变更类型","enum":["ROWS_INSERTED","ROWS_DELETED","ROWS_UPDATED","COLUMNS_INSERTED","COLUMNS_DELETED","COLUMNS_UPDATED","SHEET_CREATED","SHEET_DELETED","SHEET_UPDATED","CUSTOM_TAB_ADDED","CUSTOM_TAB_DELETED","CUSTOM_TAB_UPDATED","CELLS_INSERTED","CELLS_DELETED","RANGE_PASTED","RANGE_AUTOFILLED","RANGE_CLEARED","RANGE_CONTENT_SET","RANGE_BORDER_SET","RANGE_STYLE_SET","RANGE_TAG_SET","RANGE_SORTED","CELLS_CONTENT_SET","CELLS_STYLE_SET","CELLS_TAG_SET","DIMENSION_GROUP_ADDED","DIMENSION_GROUP_REMOVED","DIMENSION_GROUP_UPDATED","DATA_VALIDATION_SET","DATA_VALIDATION_CLEARED","CELLS_MERGED","CELLS_UNMERGED","NAMED_RANGE_SET","NAMED_RANGE_CLEARED","FEATURE_ADDED","FEATURE_DELETED","FEATURE_UPDATED","WORKBOOK_SETTING_UPDATED","EXTERNAL_REFERENCES_REPLACED","UNSUPPORTED_CHANGE"]}, + "targets":{ + "type":"array", + "description":"该 change 涉及的工作簿、工作表或范围;源和目的范围用 role 区分", + "items":{ + "type":"object", + "properties":{ + "scope":{"type":"string","description":"定位层级","enum":["WORKBOOK","SHEET","RANGE"]}, + "sheetId":{"type":"string","description":"SHEET 或 RANGE target 的稳定工作表 ID"}, + "sheetName":{"type":"string","description":"可可靠解析时的工作表显示名称"}, + "sheetNameSource":{"type":"string","description":"工作表名称来源;AT_CHANGE 为变更时名称,CURRENT_STATE 为当前状态映射,UNKNOWN 为无法确认","enum":["AT_CHANGE","CURRENT_STATE","UNKNOWN"]}, + "a1Range":{"type":"string","description":"RANGE target 的 1-based A1 范围"}, + "role":{"type":"string","description":"target 在变更中的角色;省略时等价于 AFFECTED","enum":["AFFECTED","SOURCE","DESTINATION"]} + }, + "required":["scope"], + "additionalProperties":true + } + }, + "details":{ + "type":"object", + "description":"按 change.type 归一后的前向详情;字段集合见 Sheet Skill,未返回的旧值或当前值不得推断", + "properties":{ + "cell":{ + "type":"object", + "description":"前向单元格内容;整体清除时为 {cleared:true}", + "properties":{ + "cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]}, + "value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true}, + "formula":{"type":"string","description":"本次修改写入的可读公式文本"}, + "formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]}, + "cellType":{"type":"object","description":"单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}, + "link":{"type":"object","description":"工作簿内范围链接;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true} + }, + "additionalProperties":true + }, + "cellType":{"type":"object","description":"相对内容变更的单元格类型;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}, + "tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}, + "border":{"type":"object","description":"前向边框;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个边框对象被清除","enum":[true]},"color":{"description":"边框颜色;已知属性为 null 表示清除该属性"},"style":{"description":"边框线型;已知属性为 null 表示清除该属性"}},"additionalProperties":true}, + "style":{"type":"object","description":"前向样式白名单;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}, + "properties":{"type":"object","description":"行列、工作表或自定义 Tab 的前向属性;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个属性对象被清除","enum":[true]}},"additionalProperties":true}, + "changes":{"type":"object","description":"工作簿设置的前向字段;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个设置对象被清除","enum":[true]},"mode":{"type":"string","description":"CALCULATION 的重算模式;default 表示恢复默认模式","enum":["auto","autoNoTable","manual","default"]},"iterate":{"type":["boolean","null"],"description":"CALCULATION 是否启用迭代计算;null 表示清除该设置"},"iterateCount":{"type":["number","null"],"description":"CALCULATION 的最大迭代次数;null 表示清除该设置"},"iterateDelta":{"type":["number","null"],"description":"CALCULATION 的迭代收敛阈值;null 表示清除该设置"},"enableDynamicArray":{"type":["boolean","null"],"description":"CALCULATION 是否启用动态数组;null 表示清除该设置"},"date1904":{"type":["boolean","null"],"description":"CALCULATION 是否使用 1904 日期系统;null 表示清除该设置"},"image":{"type":"object","description":"BACKGROUND 图片设置;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示背景图片设置被清除","enum":[true]},"opacity":{"type":["number","null"],"description":"背景图片不透明度;null 表示清除该属性"}},"additionalProperties":true}},"additionalProperties":true}, + "fillMode":{"type":"string","description":"RANGE_AUTOFILLED 的填充模式","enum":["copy","series","trend","predict","none"]}, + "copyStyle":{"type":"boolean","description":"RANGE_AUTOFILLED 是否复制样式;当前协议的 COMPLETE change 必有"}, + "styleMode":{"type":"string","description":"CELLS_STYLE_SET 的样式作用模式;coverStyle 未传 mode 时服务端显式返回 cell","enum":["sheet","row","col","cell"]}, + "step":{"type":"object","description":"粘贴或自动填充模式块大小","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"}},"required":["rows","columns"],"additionalProperties":true}, + "pasteMode":{"type":"string","description":"RANGE_PASTED 的粘贴作用模式","enum":["cell","row","col","sheet"]}, + "isCut":{"type":"boolean","description":"RANGE_PASTED 是否来自剪切"}, + "iterateMode":{"type":"string","description":"RANGE_PASTED 如何重复应用内容模式","enum":["step","flex"]}, + "includedParts":{"type":"array","description":"RANGE_PASTED 实际包含非空数据的变更类别;空切片不计入","items":{"type":"string","description":"实际包含的变更类别","enum":["CONTENT","STYLE","MERGES","CELL_TYPES","CONDITIONAL_FORMATTING","TABLES","PIVOT_TABLES","COMMENTS","REMINDERS","MENTIONS","DATA_VALIDATION","FILTERS","LOCKS","FOLLOWERS","PROTECTION_RANGES","DIMENSION_METADATA","REACTIONS","RANGE_TAGS"]}}, + "contentPattern":{"type":"object","description":"粘贴内容模式的安全化前向表示","properties":{"rows":{"type":"number","description":"模式块行数"},"columns":{"type":"number","description":"模式块列数"},"cells":{"type":"array","description":"相对模式块的单元格内容","items":{"type":"object","properties":{"rowOffset":{"type":"number","description":"相对模式块左上角的 0-based 行偏移"},"columnOffset":{"type":"number","description":"相对模式块左上角的 0-based 列偏移"},"cleared":{"type":"boolean","description":"true 表示整个模式单元格被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"required":["rowOffset","columnOffset"],"additionalProperties":true}}},"required":["cells"],"additionalProperties":true}, + "clearParts":{"type":"array","description":"RANGE_CLEARED 实际清除的内容类别","items":{"type":"string","description":"被清除的内容类别","enum":["VALUES","FORMULAS","MERGES","STYLES","CELL_TYPES","CONDITIONAL_FORMATTING","DATA_VALIDATION","DATA_VALIDATION_LIST","COLUMN_TYPES","LINKS","TABLES","COMMENTS","REMINDERS","REACTIONS"]}}, + "preservedCellTypes":{"type":"array","description":"清除 CELL_TYPES 时明确保留的类型;空数组表示不保留 select 或 checkbox","items":{"type":"string","description":"保留的单元格类型","enum":["SELECT","CHECKBOX"]}}, + "relativeChanges":{"type":"array","description":"相对 target 定位的前向行列属性、内容、样式或 tag 变更;sheet 级项可没有坐标","items":{"type":"object","properties":{"offset":{"type":"number","description":"ROWS_UPDATED 或 COLUMNS_UPDATED 中相对行列区间起点的 0-based 偏移"},"properties":{"type":"object","description":"offset 对应的前向行列属性或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个行列属性对象被清除","enum":[true]},"size":{"type":["number","null"],"description":"行高或列宽;null 表示清除该属性"},"customSize":{"type":["boolean","null"],"description":"是否使用自定义尺寸;null 表示清除该属性"},"hidden":{"type":["boolean","null"],"description":"是否隐藏;null 表示清除该属性"},"sticky":{"type":["boolean","null"],"description":"是否冻结;null 表示清除该属性"},"cellType":{"type":"object","description":"行列默认单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]},"options":{"type":"array","description":"select 类型的选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}},"additionalProperties":true}},"additionalProperties":true},"rowOffset":{"type":"number","description":"0-based 行偏移;行级或单元格级项使用"},"columnOffset":{"type":"number","description":"0-based 列偏移;列级或单元格级项使用"},"cell":{"type":"object","description":"前向单元格内容或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格内容对象被清除","enum":[true]},"value":{"type":"object","description":"具名类型化前向值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","MULTI"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"values":{"type":"array","description":"kind=MULTI 时的字符串值列表","items":{"type":"string","description":"多选值"}}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"本次修改写入的可读公式文本"},"formulaCleared":{"type":"boolean","description":"true 表示本次修改清除了公式","enum":[true]},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]},"type":{"type":"string","description":"单元格类型","enum":["general","checkbox","select"]}},"additionalProperties":true},"link":{"type":"object","description":"工作簿内范围链接或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个链接对象被清除","enum":[true]},"type":{"type":"string","description":"正常链接类型;当前为 range","enum":["range"]},"sheetId":{"type":"string","description":"正常链接的目标工作表 ID"},"a1Range":{"type":"string","description":"正常链接目标的 1-based A1 范围"},"absolute":{"type":"boolean","description":"正常链接是否使用绝对引用"}},"additionalProperties":true}},"additionalProperties":true},"cellType":{"type":"object","description":"前向单元格类型或 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个单元格类型对象被清除","enum":[true]}},"additionalProperties":true},"styleId":{"type":"string","description":"引用同一 change 的 styles[] 条目的 ID"},"styleCleared":{"type":"boolean","description":"true 表示该相对位置的样式被清除","enum":[true]},"tag":{"type":"object","description":"tag 清除标记;非清除的动态 tag 内容不会返回","properties":{"cleared":{"type":"boolean","description":"true 表示 tag 被清除","enum":[true]}},"required":["cleared"],"additionalProperties":false}},"additionalProperties":true}}, + "styles":{"type":"array","description":"同一 change 内可由 relativeChanges.styleId 引用的具名样式条目","items":{"type":"object","properties":{"styleId":{"type":"string","description":"样式引用 ID"},"style":{"type":"object","description":"样式白名单对象;整体清除时为 {cleared:true}","properties":{"cleared":{"type":"boolean","description":"true 表示整个样式对象被清除","enum":[true]}},"additionalProperties":true}},"required":["styleId","style"],"additionalProperties":true}}, + "dataValidation":{ + "type":"object", + "description":"DATA_VALIDATION_SET 的归一化验证规则;敏感或无法安全解释的内部字段不会返回", + "properties":{ + "type":{"type":"string","description":"数据验证规则类型"}, + "templateId":{"type":"number","description":"列表验证使用的模板 ID;存在时不代表已返回内联选项或来源区域"}, + "sourceType":{"type":"string","description":"下拉选项来源;inline 为内联选项,sourceRange 为区域引用","enum":["inline","sourceRange"]}, + "options":{"type":"array","description":"安全化后的下拉选项","items":{"type":"object","properties":{"value":{"type":"string","description":"选项值"},"color":{"type":"string","description":"可用时的选项颜色"}},"required":["value"],"additionalProperties":true}}, + "sourceRange":{"type":"object","description":"解析成功时的下拉来源区域","properties":{"sheetId":{"type":"string","description":"来源工作表 ID"},"sheetName":{"type":"string","description":"可用时的来源工作表名称"},"a1Notation":{"type":"string","description":"来源区域的 1-based A1 表示"}},"additionalProperties":true}, + "sourceRangeStatus":{"type":"string","description":"来源区域解析状态","enum":["RESOLVED","UNRESOLVED","INVALID"]}, + "sourceRangeExpression":{"type":"string","description":"安全来源区域表达式;解析为 RESOLVED 时也可能保留原表达式"}, + "enableMultiSelect":{"type":"boolean","description":"下拉是否允许多选"}, + "criteria":{"type":"object","description":"非下拉规则可安全公开的具名条件参数","properties":{"operator":{"type":"string","description":"条件运算符"},"value1":{"type":"object","description":"第一个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"value2":{"type":"object","description":"第二个具名类型化条件值","properties":{"kind":{"type":"string","description":"值类型","enum":["STRING","NUMBER","BOOLEAN","NULL","FORMULA"]},"stringValue":{"type":"string","description":"kind=STRING 时的字符串值"},"numberValue":{"type":"number","description":"kind=NUMBER 时的数值"},"booleanValue":{"type":"boolean","description":"kind=BOOLEAN 时的布尔值"},"formula":{"type":"string","description":"kind=FORMULA 时的公式文本"}},"required":["kind"],"additionalProperties":true},"formula":{"type":"string","description":"条件公式"}},"additionalProperties":true}, + "settings":{"type":"object","description":"可安全公开的数据验证通用行为设置;已知属性为 null 表示清除该属性","properties":{"allowBlank":{"type":["boolean","null"],"description":"是否允许空值;null 表示清除该设置"},"errorStyle":{"type":["string","null"],"description":"验证失败时的错误样式;null 表示清除该设置"},"showInputMessage":{"type":["boolean","null"],"description":"是否显示输入提示;null 表示清除该设置"},"showErrorMessage":{"type":["boolean","null"],"description":"是否显示错误提示;null 表示清除该设置"},"prompt":{"type":["string","null"],"description":"输入提示文本;null 表示清除该设置"},"error":{"type":["string","null"],"description":"验证失败提示文本;null 表示清除该设置"},"requiredInRow":{"type":["boolean","null"],"description":"是否要求行内必填;null 表示清除该设置"},"showDropDown":{"type":["boolean","null"],"description":"是否显示下拉控件;null 表示清除该设置"},"columnType":{"type":["boolean","null"],"description":"是否启用列类型行为;null 表示清除该设置"},"promptTitle":{"type":["string","null"],"description":"输入提示标题;null 表示清除该设置"},"errorTitle":{"type":["string","null"],"description":"验证失败提示标题;null 表示清除该设置"},"imeMode":{"type":["string","null"],"description":"输入法模式;null 表示清除该设置"}},"additionalProperties":true} + }, + "required":["type"], + "additionalProperties":true + } + }, + "additionalProperties":true + }, + "detailsStatus":{"type":"string","description":"details 的完整度;COMPLETE 仍只代表前向提交详情,不代表 old/current 状态完整","enum":["COMPLETE","PARTIAL","UNAVAILABLE"]}, + "omissions":{ + "type":"array", + "description":"PARTIAL 或 UNAVAILABLE 时的稳定省略原因;COMPLETE 时固定为空数组", + "items":{"type":"object","properties":{"code":{"type":"string","description":"稳定的省略原因码","enum":["MISSING_REQUIRED_FIELD","PLUGIN_OPERATION","UNSUPPORTED_WRAPPER","UNSUPPORTED_PROTOCOL_VERSION","UNKNOWN_ACTION","UNKNOWN_VARIANT","DETAILS_NOT_FULLY_INTERPRETED","INVALID_TARGET","VALUE_NOT_AGENT_READABLE","FORMULA_TEXT_UNAVAILABLE","SOURCE_RANGE_UNRESOLVED","SOURCE_RANGE_INVALID","SENSITIVE_DETAILS_OMITTED"]},"fields":{"type":"array","description":"受该原因影响的 details 字段路径","items":{"type":"string","description":"被省略或不完整的字段路径"}}},"required":["code"],"additionalProperties":true} + } + }, + "required":["type","targets","details","detailsStatus","omissions"], + "additionalProperties":true + } + } + }, + "required":["revision","createTime","isSelfEdit","eventType","detailsStatus","changes"], + "additionalProperties":true + } + } + }, + "required":["success","logId","schemaVersion","changeSemantics","latestRevision","startRevision","endRevision","summary","changesets"], + "additionalProperties":true + }`), +} + +type sheetPublishedResultSchemaCache struct { + once sync.Once + schema map[string]any + err error +} + +var ( + sheetRevisionPublishedSchemaCache sheetPublishedResultSchemaCache + sheetChangesetPublishedSchemaCache sheetPublishedResultSchemaCache +) + +func newSheetRevisionCmds() []*cobra.Command { + return []*cobra.Command{newSheetRevisionGetCmd(), newSheetChangesetGetCmd()} +} + +func newSheetRevisionGetCmd() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "revision-get", + Short: "获取表格工作簿当前 revision", + Long: "获取在线电子表格工作簿当前持久化 revision。该能力是工作簿级的,不接收 --sheet-id;--node 可传文档 ID 或完整 URL。服务端业务 JSON 原样放入统一输出的 data。", + Example: " dws sheet revision-get --node --format json", + OutputRollout: output.RolloutUnifiedActive, + Server: "sheet", + Tool: sheetRevisionGetRemoteTool, + Flags: []LeafFlag{ + {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"}, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: sheetRevisionGetRemoteTool, + CanonicalPath: "sheet." + sheetRevisionGetRemoteTool, + CLIPath: "sheet revision-get", + PrimaryCLIPath: "sheet revision-get", + }, + Description: "获取在线电子表格工作簿当前持久化 revision;结果为后续 changeset 区间查询的 revision 锚点。", + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, + Result: sheetRevisionResult, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetRevisionGetRemoteTool}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "获取在线电子表格工作簿当前 Delta revision,作为 changeset 查询锚点。", + UseWhen: []string{"需要知道当前 revision,或准备按 revision 区间复核工作簿编辑时"}, + AvoidWhen: []string{"要查看可命名/可回滚的历史快照时用 sheet version list;要读当前单元格值时用 csv-get、table-get 或 range read"}, + Examples: []string{"dws sheet revision-get --node --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"}, + }, + }, + ResultCall: callSheetRevisionResult, + }) +} + +func newSheetChangesetGetCmd() *cobra.Command { + return NewLeafCommand(LeafSpec{ + Use: "changeset-get", + Short: "获取表格工作簿 revision 区间内的 changeset", + Long: `获取在线电子表格工作簿在 (startRevision, endRevision] 区间内的连续、Agent 可解释的前向 changeset。 + +该能力是工作簿级的,不接收 --sheet-id。--end-revision 省略时由服务端在请求开始时固定为 latestRevision;单次区间最多 20 个 revision。统一输出 data.changesets 始终是可直接遍历的 JSON 数组;CLI 只解码服务端传输格式,不改写语义化 change。changeset 只描述当时提交的前向变更,不提供统一 old/current 值;确认最终状态必须另行回读。`, + Example: ` dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json + dws sheet changeset-get --node --start-revision 120 --format json`, + OutputRollout: output.RolloutUnifiedActive, + Server: "sheet", + Tool: sheetChangesetGetRemoteTool, + Flags: []LeafFlag{ + {Name: "node", Usage: "表格文档 ID 或 URL (必填)", Bind: "nodeId", Required: true, Trim: true, Example: "https://alidocs.dingtalk.com/i/nodes/xxx"}, + { + Name: "start-revision", Usage: "查询基线 revision (必填,非负;结果不包含该 revision)", Bind: "startRevision", + Required: true, Trim: true, Example: "120", Transform: sheetRevisionNumberArg, + }, + { + Name: "end-revision", Usage: "查询结束 revision (可选,非负;结果包含该 revision)", Bind: "endRevision", + Trim: true, OmitEmpty: true, Example: "121", Transform: sheetRevisionNumberArg, + }, + }, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: LeafContract{ + Identity: contract.ToolIdentitySpec{ + ProductID: "sheet", + Name: sheetChangesetGetRemoteTool, + CanonicalPath: "sheet." + sheetChangesetGetRemoteTool, + CLIPath: "sheet changeset-get", + PrimaryCLIPath: "sheet changeset-get", + }, + Description: "获取在线电子表格工作簿 revision 区间内连续、语义化的前向 changeset;区间语义为 (startRevision, endRevision]。", + DryRun: &contract.DryRunSpec{PreviewKind: contract.DryRunPreviewRequest, RemoteReads: false}, + Result: sheetChangesetResult, + Interface: &contract.InterfaceSpec{ + Mode: contract.InterfaceModeMCP, + Availability: contract.InterfaceAvailable, + Ref: &contract.InterfaceRefSpec{ProductID: "sheet", RPCName: sheetChangesetGetRemoteTool}, + }, + Selection: contract.SelectionSpec{ + AgentSummary: "读取工作簿两个 revision 之间的语义化前向 changeset,区分 EDIT、UNDO 与 STATE_RESET。", + UseWhen: []string{"已知起始 revision,需要复核之后发生了哪些工作簿级编辑或回滚时"}, + AvoidWhen: []string{"要读取单元格当前最终值时用 csv-get、table-get 或 range read;要查看或回滚命名历史快照时用 sheet version list/revert"}, + Examples: []string{"dws sheet changeset-get --node --start-revision 120 --end-revision 121 --format json"}, + }, + Parameters: []contract.ParamDecl{ + {Name: "node", Property: "nodeId", Description: "在线电子表格文档 ID 或完整 URL"}, + {Name: "start-revision", Property: "startRevision", InterfaceType: "number", Description: "非负查询基线;返回区间不包含该 revision"}, + {Name: "end-revision", Property: "endRevision", InterfaceType: "number", Description: "可选非负结束 revision;返回区间包含该 revision"}, + }, + }, + Validate: validateSheetChangesetRange, + ResultCall: callSheetRevisionResult, + }) +} + +func callSheetRevisionResult(cmd *cobra.Command, tool string, args map[string]any) (output.CommandResult, error) { + if deps.Caller.DryRun() { + return output.Success(map[string]any{ + "executed": false, + "tool": tool, + "arguments": args, + }, output.WithDryRun()), nil + } + raw, err := callMCPToolReturnTextOnServer(cmd.Context(), "sheet", tool, args) + if err != nil { + return nil, err + } + data, err := decodeSheetRevisionResult(tool, args, raw) + if err != nil { + return nil, err + } + return output.Success(data), nil +} + +func decodeSheetRevisionResult(tool string, request map[string]any, raw string) (any, error) { + if strings.TrimSpace(raw) == "" { + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned no non-empty text content", + "empty_tool_response", + true, + ) + } + data, err := decodeSheetSingleJSON(raw) + if err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned invalid JSON: %v", err), + "invalid_tool_response", + false, + ) + } + object, ok := data.(map[string]any) + if !ok { + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned a non-object business result", + "invalid_tool_response", + false, + ) + } + if isBusinessError(object) { + return nil, &CLIError{ + Code: CodeMCPToolError, + Message: raw, + Suggestion: suggestForBusinessError(object), + Operation: "sheet/" + tool, + } + } + success, ok := object["success"].(bool) + if !ok || !success { + return nil, invalidSheetRevisionResponse(tool, + "MCP sheet read tool returned a business result without success=true", + "invalid_tool_response", + false, + ) + } + if tool == sheetChangesetGetRemoteTool { + if err := normalizeSheetChangesetTransport(object); err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned invalid changeset data: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) + } + } + if err := validateSheetPublishedResult(tool, object); err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned data that does not match its published result contract: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) + } + if tool == sheetChangesetGetRemoteTool { + if err := validateSheetChangesetAudit(request, object); err != nil { + return nil, invalidSheetRevisionResponse(tool, + fmt.Sprintf("MCP sheet read tool returned changeset data that does not match the requested interval or its summary: %v%s", + err, sheetResultLogIDSuffix(object)), + "invalid_tool_response", + false, + ) + } + } + return object, nil +} + +func invalidSheetRevisionResponse(tool, message, reason string, retryable bool) error { + return apperrors.NewAPI(message, + apperrors.WithOperation("sheet/"+tool), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(retryable), + apperrors.WithReason(reason), + ) +} + +func sheetResultLogIDSuffix(object map[string]any) string { + logID, ok := object["logId"].(string) + if !ok || strings.TrimSpace(logID) == "" { + return "" + } + return fmt.Sprintf(" (logId=%s)", strings.TrimSpace(logID)) +} + +func decodeSheetSingleJSON(raw string) (any, error) { + decoder := json.NewDecoder(strings.NewReader(raw)) + decoder.UseNumber() + var data any + if err := decoder.Decode(&data); err != nil { + return nil, err + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + if err == nil { + err = fmt.Errorf("包含多个 JSON 值") + } + return nil, err + } + return data, nil +} + +func normalizeSheetChangesetTransport(object map[string]any) error { + encoded, hasEncoded := object["changesetsJson"] + if hasEncoded { + changesetsJSON, ok := encoded.(string) + if !ok { + return fmt.Errorf("changesetsJson 不是字符串") + } + if strings.TrimSpace(changesetsJSON) == "" { + return fmt.Errorf("changesetsJson 为空") + } + if len(changesetsJSON) > sheetChangesetJSONMaxBytes { + return fmt.Errorf("changesetsJson 超过 %d 字节", sheetChangesetJSONMaxBytes) + } + decoded, err := decodeSheetSingleJSON(changesetsJSON) + if err != nil { + return fmt.Errorf("changesetsJson 不是完整 JSON: %v", err) + } + changesets, ok := decoded.([]any) + if !ok { + return fmt.Errorf("changesetsJson 根节点不是数组") + } + object["changesets"] = changesets + delete(object, "changesetsJson") + return nil + } + + if legacy, exists := object["changesets"]; exists { + if _, ok := legacy.([]any); !ok { + return fmt.Errorf("changesets 不是数组") + } + return nil + } + + return fmt.Errorf("成功响应缺少 changesetsJson") +} + +func validateSheetPublishedResult(tool string, object map[string]any) error { + var rawSchema json.RawMessage + var cache *sheetPublishedResultSchemaCache + switch tool { + case sheetRevisionGetRemoteTool: + rawSchema = sheetRevisionResult.DataSchema + cache = &sheetRevisionPublishedSchemaCache + case sheetChangesetGetRemoteTool: + rawSchema = sheetChangesetResult.DataSchema + cache = &sheetChangesetPublishedSchemaCache + default: + return fmt.Errorf("未知工具 %q", tool) + } + return validateSheetPublishedResultWithSchema(tool, object, rawSchema, cache) +} + +func validateSheetPublishedResultWithSchema(tool string, object map[string]any, + rawSchema json.RawMessage, cache *sheetPublishedResultSchemaCache, +) error { + cache.once.Do(func() { cache.err = json.Unmarshal(rawSchema, &cache.schema) }) + if cache.err != nil { + return fmt.Errorf("读取已发布 Result Schema 失败: %v", cache.err) + } + schema := cache.schema + if err := cli.ValidateJSONSchemaValue(object, schema); err != nil { + return err + } + if strings.TrimSpace(object["logId"].(string)) == "" { + return fmt.Errorf("$.logId 不能为空") + } + + switch tool { + case sheetRevisionGetRemoteTool: + if _, err := sheetNonNegativeInteger(object["revision"]); err != nil { + return fmt.Errorf("$.revision %v", err) + } + case sheetChangesetGetRemoteTool: + latest, err := sheetNonNegativeInteger(object["latestRevision"]) + if err != nil { + return fmt.Errorf("$.latestRevision %v", err) + } + start, err := sheetNonNegativeInteger(object["startRevision"]) + if err != nil { + return fmt.Errorf("$.startRevision %v", err) + } + end, err := sheetNonNegativeInteger(object["endRevision"]) + if err != nil { + return fmt.Errorf("$.endRevision %v", err) + } + if start > end { + return fmt.Errorf("$.startRevision 不能大于 $.endRevision") + } + if end > latest { + return fmt.Errorf("$.endRevision 不能大于 $.latestRevision") + } + } + return nil +} + +type sheetChangesetSummaryAudit struct { + changeCount int64 + completeChangeCount int64 + partialChangeCount int64 + unsupportedChangeCount int64 + containsStateReset bool + containsIncompleteChanges bool + affectedSheets []sheetChangesetAffectedSheetAudit +} + +type sheetChangesetAffectedSheetAudit struct { + sheetID string + sheetName string + ranges []string +} + +type sheetChangesetAffectedSheetAccumulator struct { + sheetName string + ranges map[string]struct{} +} + +func validateSheetChangesetAudit(request, object map[string]any) error { + requestStart := request["startRevision"].(int64) + responseStart, _ := sheetNonNegativeInteger(object["startRevision"]) + responseEnd, _ := sheetNonNegativeInteger(object["endRevision"]) + latest, _ := sheetNonNegativeInteger(object["latestRevision"]) + if responseStart != requestStart { + return fmt.Errorf("$.startRevision=%d,与请求值 %d 不一致", responseStart, requestStart) + } + if requestEnd, exists := request["endRevision"]; exists { + if responseEnd != requestEnd.(int64) { + return fmt.Errorf("$.endRevision=%d,与请求值 %d 不一致", responseEnd, requestEnd) + } + } else if responseEnd != latest { + return fmt.Errorf("省略 endRevision 时 $.endRevision=%d,必须等于 $.latestRevision=%d", responseEnd, latest) + } + if responseEnd-responseStart > sheetChangesetMaxSpan { + return fmt.Errorf("响应区间超过 %d 个 revision", sheetChangesetMaxSpan) + } + + changesets := object["changesets"].([]any) + wantCount := responseEnd - responseStart + if int64(len(changesets)) != wantCount { + return fmt.Errorf("$.changesets 数量为 %d,无法完整覆盖 (%d,%d]", len(changesets), responseStart, responseEnd) + } + for index, rawChangeset := range changesets { + changeset := rawChangeset.(map[string]any) + revision, err := sheetNonNegativeInteger(changeset["revision"]) + if err != nil { + return fmt.Errorf("$.changesets[%d].revision %v", index, err) + } + expected := responseStart + int64(index) + 1 + if revision != expected { + return fmt.Errorf("$.changesets[%d].revision=%d,期望连续 revision %d", index, revision, expected) + } + } + + actualSummary, err := parseSheetChangesetSummaryAudit(object["summary"].(map[string]any)) + if err != nil { + return err + } + expectedSummary := summarizeSheetChangesetsForAudit(changesets) + if !reflect.DeepEqual(actualSummary, expectedSummary) { + return fmt.Errorf("$.summary 与 $.changesets 复算结果不一致") + } + return nil +} + +func parseSheetChangesetSummaryAudit(raw map[string]any) (sheetChangesetSummaryAudit, error) { + fields := []string{"changeCount", "completeChangeCount", "partialChangeCount", "unsupportedChangeCount"} + counts := make([]int64, len(fields)) + for index, field := range fields { + value, err := sheetNonNegativeInteger(raw[field]) + if err != nil { + return sheetChangesetSummaryAudit{}, fmt.Errorf("$.summary.%s %v", field, err) + } + counts[index] = value + } + + result := sheetChangesetSummaryAudit{ + changeCount: counts[0], + completeChangeCount: counts[1], + partialChangeCount: counts[2], + unsupportedChangeCount: counts[3], + containsStateReset: raw["containsStateReset"].(bool), + containsIncompleteChanges: raw["containsIncompleteChanges"].(bool), + } + for _, rawSheet := range raw["affectedSheets"].([]any) { + sheet := rawSheet.(map[string]any) + rawRanges := sheet["ranges"].([]any) + affected := sheetChangesetAffectedSheetAudit{ + sheetID: sheet["sheetId"].(string), + ranges: make([]string, 0, len(rawRanges)), + } + affected.sheetName, _ = sheet["sheetName"].(string) + for _, rawRange := range rawRanges { + affected.ranges = append(affected.ranges, rawRange.(string)) + } + result.affectedSheets = append(result.affectedSheets, affected) + } + return result, nil +} + +func summarizeSheetChangesetsForAudit(changesets []any) sheetChangesetSummaryAudit { + result := sheetChangesetSummaryAudit{} + affected := map[string]*sheetChangesetAffectedSheetAccumulator{} + for _, rawChangeset := range changesets { + changeset := rawChangeset.(map[string]any) + result.containsStateReset = result.containsStateReset || changeset["eventType"] == "STATE_RESET" + result.containsIncompleteChanges = result.containsIncompleteChanges || changeset["detailsStatus"] != "COMPLETE" + for _, rawChange := range changeset["changes"].([]any) { + change := rawChange.(map[string]any) + result.changeCount++ + switch change["detailsStatus"] { + case "COMPLETE": + result.completeChangeCount++ + case "PARTIAL": + result.partialChangeCount++ + } + if change["type"] == "UNSUPPORTED_CHANGE" { + result.unsupportedChangeCount++ + } + for _, rawTarget := range change["targets"].([]any) { + target := rawTarget.(map[string]any) + if target["role"] == "SOURCE" { + continue + } + sheetID, _ := target["sheetId"].(string) + if strings.TrimSpace(sheetID) == "" { + continue + } + accumulator := affected[sheetID] + if accumulator == nil { + accumulator = &sheetChangesetAffectedSheetAccumulator{ranges: map[string]struct{}{}} + affected[sheetID] = accumulator + } + if sheetName, ok := target["sheetName"].(string); ok && strings.TrimSpace(sheetName) != "" { + accumulator.sheetName = sheetName + } + if a1Range, ok := target["a1Range"].(string); ok && strings.TrimSpace(a1Range) != "" { + accumulator.ranges[a1Range] = struct{}{} + } + } + } + } + + sheetIDs := make([]string, 0, len(affected)) + for sheetID := range affected { + sheetIDs = append(sheetIDs, sheetID) + } + sort.Strings(sheetIDs) + for _, sheetID := range sheetIDs { + accumulator := affected[sheetID] + ranges := make([]string, 0, len(accumulator.ranges)) + for a1Range := range accumulator.ranges { + ranges = append(ranges, a1Range) + } + sort.Strings(ranges) + result.affectedSheets = append(result.affectedSheets, sheetChangesetAffectedSheetAudit{ + sheetID: sheetID, sheetName: accumulator.sheetName, ranges: ranges, + }) + } + return result +} + +func sheetNonNegativeInteger(value any) (int64, error) { + number, ok := value.(json.Number) + if !ok { + return 0, fmt.Errorf("必须是非负整数") + } + parsed, err := strconv.ParseInt(number.String(), 10, 64) + if err != nil || parsed < 0 { + return 0, fmt.Errorf("必须是非负整数") + } + return parsed, nil +} + +func sheetRevisionNumberArg(raw string) (any, error) { + return strconv.ParseInt(strings.TrimSpace(raw), 10, 64) +} + +func validateSheetChangesetRange(cmd *cobra.Command, _ []string) error { + start, err := parseSheetRevisionFlag(cmd, "start-revision") + if err != nil { + return err + } + if start < 0 { + return apperrors.NewValidation("--start-revision 必须是非负整数") + } + + endRaw, _ := cmd.Flags().GetString("end-revision") + if strings.TrimSpace(endRaw) == "" { + return nil + } + end, err := parseSheetRevisionFlag(cmd, "end-revision") + if err != nil { + return err + } + if end < 0 { + return apperrors.NewValidation("--end-revision 必须是非负整数") + } + if end < start { + return apperrors.NewValidation("--end-revision 必须大于或等于 --start-revision") + } + if end-start > sheetChangesetMaxSpan { + return apperrors.NewValidation(fmt.Sprintf( + "单次最多查询 %d 个 revision;请把 --end-revision 调整为不大于 %d", + sheetChangesetMaxSpan, start+sheetChangesetMaxSpan, + )) + } + return nil +} + +func parseSheetRevisionFlag(cmd *cobra.Command, name string) (int64, error) { + raw, _ := cmd.Flags().GetString(name) + value, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 64) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是 64 位整数", name)) + } + return value, nil +} diff --git a/internal/helpers/sheet_revision_test.go b/internal/helpers/sheet_revision_test.go new file mode 100644 index 00000000..6be2ba14 --- /dev/null +++ b/internal/helpers/sheet_revision_test.go @@ -0,0 +1,1092 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/spf13/cobra" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type sheetRevisionTestCall struct { + server string + tool string + args map[string]any +} + +type sheetRevisionTestCaller struct { + responses map[string]string + calls []sheetRevisionTestCall + dryRun bool +} + +func (c *sheetRevisionTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) { + cloned := make(map[string]any, len(args)) + for key, value := range args { + cloned[key] = value + } + c.calls = append(c.calls, sheetRevisionTestCall{server: server, tool: tool, args: cloned}) + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.responses[tool]}}}, nil +} + +func (*sheetRevisionTestCaller) Format() string { return "json" } +func (c *sheetRevisionTestCaller) DryRun() bool { return c.dryRun } +func (*sheetRevisionTestCaller) Fields() string { return "" } +func (*sheetRevisionTestCaller) JQ() string { return "" } + +func executeSheetRevisionCommand(t *testing.T, caller *sheetRevisionTestCaller, command *cobra.Command, args ...string) (string, error) { + t.Helper() + previousDeps := deps + t.Cleanup(func() { deps = previousDeps }) + + InitDeps(caller) + var stdout bytes.Buffer + deps.Out.w = &stdout + deps.Out.errW = io.Discard + command = prepareUnifiedTestCommand(command) + command.SetOut(&stdout) + command.SetErr(io.Discard) + command.SilenceErrors = true + command.SilenceUsage = true + command.SetArgs(args) + err := command.Execute() + return stdout.String(), err +} + +func assertSheetRevisionJSONEqual(t *testing.T, got, want string) { + t.Helper() + var envelope map[string]any + if err := json.Unmarshal([]byte(got), &envelope); err != nil { + t.Fatalf("decode command output: %v\noutput: %s", err, got) + } + if envelope["ok"] != true || envelope["outcome"] != "success" { + t.Fatalf("command envelope = %#v, want success", envelope) + } + var wantValue any + if err := json.Unmarshal([]byte(want), &wantValue); err != nil { + t.Fatalf("decode expected JSON: %v", err) + } + if !reflect.DeepEqual(envelope["data"], wantValue) { + t.Fatalf("command data = %#v, want raw MCP business JSON %#v", envelope["data"], wantValue) + } +} + +func encodeSheetChangesetTransport(t *testing.T, logical string) string { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(logical)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + t.Fatalf("decode logical changeset fixture: %v", err) + } + return encodeSheetChangesetTransportObject(t, object) +} + +func encodeSheetChangesetTransportObject(t *testing.T, object map[string]any) string { + t.Helper() + changesets, ok := object["changesets"] + if !ok { + t.Fatal("logical changeset fixture missing changesets") + } + encodedChangesets, err := json.Marshal(changesets) + if err != nil { + t.Fatalf("encode changesets fixture: %v", err) + } + delete(object, "changesets") + object["changesetsJson"] = string(encodedChangesets) + encoded, err := json.Marshal(object) + if err != nil { + t.Fatalf("encode transport fixture: %v", err) + } + return string(encoded) +} + +func decodeSheetChangesetLogicalObject(t *testing.T, logical string) map[string]any { + t.Helper() + decoder := json.NewDecoder(strings.NewReader(logical)) + decoder.UseNumber() + var object map[string]any + if err := decoder.Decode(&object); err != nil { + t.Fatalf("decode logical changeset fixture: %v", err) + } + return object +} + +const sheetChangesetAuditLogicalResponse = `{ + "success":true, + "logId":"trace-audit", + "schemaVersion":2, + "changeSemantics":"FORWARD_ONLY", + "latestRevision":12, + "startRevision":10, + "endRevision":12, + "summary":{ + "changeCount":3, + "completeChangeCount":1, + "partialChangeCount":1, + "unsupportedChangeCount":1, + "containsStateReset":true, + "containsIncompleteChanges":true, + "affectedSheets":[ + {"sheetId":"sheet-a","sheetName":"Alpha","ranges":["C3"]}, + {"sheetId":"sheet-b","sheetName":"Beta","ranges":["A1","B2"]} + ] + }, + "changesets":[ + { + "revision":11, + "createTime":"2026-08-21T10:00:00+08:00", + "isSelfEdit":true, + "eventType":"EDIT", + "detailsStatus":"UNAVAILABLE", + "changes":[ + { + "type":"RANGE_CONTENT_SET", + "targets":[ + {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"B2","role":"AFFECTED"}, + {"scope":"RANGE","sheetId":"sheet-b","sheetName":"Beta","a1Range":"A1","role":"DESTINATION"}, + {"scope":"WORKBOOK","role":"AFFECTED"} + ], + "details":{}, + "detailsStatus":"COMPLETE", + "omissions":[] + }, + { + "type":"RANGE_STYLE_SET", + "targets":[{"scope":"RANGE","sheetId":"sheet-a","sheetName":"Alpha","a1Range":"C3","role":"AFFECTED"}], + "details":{}, + "detailsStatus":"PARTIAL", + "omissions":[{"code":"DETAILS_NOT_FULLY_INTERPRETED"}] + }, + { + "type":"UNSUPPORTED_CHANGE", + "targets":[{"scope":"RANGE","sheetId":"sheet-source","a1Range":"D4","role":"SOURCE"}], + "details":{}, + "detailsStatus":"UNAVAILABLE", + "omissions":[{"code":"UNKNOWN_ACTION"}] + } + ] + }, + { + "revision":12, + "createTime":"2026-08-21T10:01:00+08:00", + "isSelfEdit":false, + "eventType":"STATE_RESET", + "detailsStatus":"COMPLETE", + "reset":{"type":"OVERWRITE","targetStatus":"NOT_APPLICABLE"}, + "changes":[] + } + ] +}` + +func TestCrossPlatformCoverageSheetRevisionGetPassesNodeURLAndPreservesJSONResult(t *testing.T) { + const response = `{"success":true,"logId":"trace-revision","revision":142,"futureField":{"kept":true,"largeInteger":9007199254740993}}` + caller := &sheetRevisionTestCaller{responses: map[string]string{sheetRevisionGetRemoteTool: response}} + nodeURL := "https://alidocs.dingtalk.com/spreadsheetv2/key/path?sheet=one" + + got, err := executeSheetRevisionCommand(t, caller, newSheetRevisionGetCmd(), "--node", nodeURL) + if err != nil { + t.Fatalf("revision-get returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + call := caller.calls[0] + if call.server != "sheet" || call.tool != sheetRevisionGetRemoteTool { + t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetRevisionGetRemoteTool) + } + if !reflect.DeepEqual(call.args, map[string]any{"nodeId": nodeURL}) { + t.Fatalf("args = %#v, want node URL passed through", call.args) + } + assertSheetRevisionJSONEqual(t, got, response) + if !strings.Contains(got, "9007199254740993") { + t.Fatalf("large JSON integer lost precision: %s", got) + } +} + +func TestCrossPlatformCoverageSheetChangesetGetAcceptsZeroStartAndOmitsOptionalEnd(t *testing.T) { + const logicalResponse = `{ + "success":true, + "logId":"trace-changeset", + "schemaVersion":2, + "changeSemantics":"FORWARD_ONLY", + "latestRevision":1, + "startRevision":0, + "endRevision":1, + "summary":{ + "changeCount":1, + "completeChangeCount":1, + "partialChangeCount":0, + "unsupportedChangeCount":0, + "containsStateReset":false, + "containsIncompleteChanges":false, + "affectedSheets":[{"sheetId":"sheet-id","sheetName":"项目","ranges":["C2"]}] + }, + "changesets":[{ + "revision":1, + "createTime":"2026-08-18T10:00:00+08:00", + "isSelfEdit":true, + "eventType":"EDIT", + "detailsStatus":"COMPLETE", + "changes":[{ + "type":"RANGE_CONTENT_SET", + "targets":[{"scope":"RANGE","sheetId":"sheet-id","sheetName":"项目","sheetNameSource":"AT_CHANGE","a1Range":"C2","role":"AFFECTED"}], + "details":{"cell":{"value":{"kind":"STRING","stringValue":"完成"}},"futureSafeField":{"x":1}}, + "detailsStatus":"COMPLETE", + "omissions":[] + }] + }] + }` + response := encodeSheetChangesetTransport(t, logicalResponse) + caller := &sheetRevisionTestCaller{responses: map[string]string{sheetChangesetGetRemoteTool: response}} + + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "0") + if err != nil { + t.Fatalf("changeset-get returned error: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls = %#v, want one call", caller.calls) + } + call := caller.calls[0] + if call.server != "sheet" || call.tool != sheetChangesetGetRemoteTool { + t.Fatalf("dispatch = %s/%s, want sheet/%s", call.server, call.tool, sheetChangesetGetRemoteTool) + } + if gotStart, exists := call.args["startRevision"]; !exists || gotStart != int64(0) { + t.Fatalf("startRevision = %#v (exists=%v), want int64(0)", gotStart, exists) + } + if _, exists := call.args["endRevision"]; exists { + t.Fatalf("optional endRevision unexpectedly sent: %#v", call.args) + } + if call.args["nodeId"] != "node-1" { + t.Fatalf("nodeId = %#v", call.args["nodeId"]) + } + assertSheetRevisionJSONEqual(t, got, logicalResponse) + if strings.Contains(got, "changesetsJson") { + t.Fatalf("transport field leaked into Agent output: %s", got) + } +} + +func TestCrossPlatformCoverageSheetChangesetGetSendsNumericEndRevision(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-changeset","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":121,"startRevision":120,"endRevision":121,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":121,"createTime":"2026-08-21T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`), + }} + _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "120", "--end-revision", "121") + if err != nil { + t.Fatalf("changeset-get returned error: %v", err) + } + want := map[string]any{"nodeId": "node-1", "startRevision": int64(120), "endRevision": int64(121)} + if len(caller.calls) != 1 || !reflect.DeepEqual(caller.calls[0].args, want) { + t.Fatalf("calls = %#v, want args %#v", caller.calls, want) + } +} + +func TestCrossPlatformCoverageSheetChangesetGetAcceptsConsistentIntervalAndSummary(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransport(t, sheetChangesetAuditLogicalResponse), + }} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "10", "--end-revision", "12") + if err != nil { + t.Fatalf("consistent changeset response returned error: %v", err) + } + assertSheetRevisionJSONEqual(t, got, sheetChangesetAuditLogicalResponse) +} + +func TestCrossPlatformCoverageSheetChangesetGetRejectsIntervalAndSummaryDrift(t *testing.T) { + tests := []struct { + name string + args []string + mutate func(map[string]any) + want string + }{ + { + name: "wrong response start", want: "与请求值 10 不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { object["startRevision"] = json.Number("9") }, + }, + { + name: "wrong explicit response end", want: "与请求值 12 不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") }, + }, + { + name: "omitted end is not latest", want: "必须等于 $.latestRevision", + args: []string{"--node", "node-1", "--start-revision", "10"}, + mutate: func(object map[string]any) { object["endRevision"] = json.Number("11") }, + }, + { + name: "response span too large", want: "响应区间超过 20 个 revision", + args: []string{"--node", "node-1", "--start-revision", "10"}, + mutate: func(object map[string]any) { + object["endRevision"] = json.Number("31") + object["latestRevision"] = json.Number("31") + }, + }, + { + name: "missing revision", want: "无法完整覆盖 (10,12]", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + changesets := object["changesets"].([]any) + object["changesets"] = changesets[1:] + }, + }, + { + name: "out of order revisions", want: "期望连续 revision 11", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + changesets := object["changesets"].([]any) + object["changesets"] = []any{changesets[1], changesets[0]} + }, + }, + { + name: "fractional changeset revision", want: "必须是非负整数", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["changesets"].([]any)[0].(map[string]any)["revision"] = json.Number("11.5") + }, + }, + { + name: "fractional summary count", want: "$.summary.changeCount 必须是非负整数", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["changeCount"] = json.Number("3.5") + }, + }, + { + name: "forged summary count", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["changeCount"] = json.Number("2") + }, + }, + { + name: "forged incomplete flag", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["containsIncompleteChanges"] = false + }, + }, + { + name: "forged affected sheets", want: "复算结果不一致", + args: []string{"--node", "node-1", "--start-revision", "10", "--end-revision", "12"}, + mutate: func(object map[string]any) { + object["summary"].(map[string]any)["affectedSheets"] = []any{} + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + object := decodeSheetChangesetLogicalObject(t, sheetChangesetAuditLogicalResponse) + test.mutate(object) + caller := &sheetRevisionTestCaller{responses: map[string]string{ + sheetChangesetGetRemoteTool: encodeSheetChangesetTransportObject(t, object), + }} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+sheetChangesetGetRemoteTool || apiErr.FailureStage != "response_validation" { + t.Fatalf("audit drift error = %#v", err) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("audit drift emitted a success envelope: %s", got) + } + }) + } +} + +func TestCrossPlatformCoverageDecodeSheetChangesetTransportAcceptsLegacyArray(t *testing.T) { + const response = `{"success":true,"logId":"trace-legacy","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":9007199254740993,"startRevision":9007199254740992,"endRevision":9007199254740993,"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]},"changesets":[{"revision":9007199254740993,"createTime":"2026-08-20T10:00:00+08:00","isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}` + decoded, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, map[string]any{ + "startRevision": int64(9007199254740992), + "endRevision": int64(9007199254740993), + }, response) + if err != nil { + t.Fatalf("decode legacy array: %v", err) + } + object := decoded.(map[string]any) + changesets := object["changesets"].([]any) + revision := changesets[0].(map[string]any)["revision"].(json.Number) + if revision.String() != "9007199254740993" { + t.Fatalf("legacy revision = %s, want exact integer", revision) + } +} + +func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsInvalidJSONStrings(t *testing.T) { + tests := []struct { + name string + raw string + }{ + {name: "not string", raw: `{"success":true,"changesetsJson":[]}`}, + {name: "empty", raw: `{"success":true,"changesetsJson":""}`}, + {name: "not json", raw: `{"success":true,"changesetsJson":"not-json"}`}, + {name: "object root", raw: `{"success":true,"changesetsJson":"{}"}`}, + {name: "multiple values", raw: `{"success":true,"changesetsJson":"[] []"}`}, + {name: "missing", raw: `{"success":true}`}, + {name: "non object response", raw: `[]`}, + {name: "legacy changesets not array", raw: `{"success":true,"changesets":{}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, + map[string]any{"startRevision": int64(0)}, test.raw); err == nil { + t.Fatalf("decode %s unexpectedly succeeded", test.raw) + } + }) + } +} + +func TestCrossPlatformCoverageDecodeSheetRevisionProtocolFailuresHaveMCPResponseMetadata(t *testing.T) { + tests := []struct { + name string + tool string + response string + wantLogID string + }{ + { + name: "malformed outer json", tool: sheetRevisionGetRemoteTool, + response: `{"success":true`, + }, + { + name: "malformed changesets json", tool: sheetChangesetGetRemoteTool, + response: `{"success":true,"logId":"trace-malformed-changeset","changesetsJson":"not-json"}`, + wantLogID: "trace-malformed-changeset", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := decodeSheetRevisionResult(test.tool, map[string]any{"startRevision": int64(0)}, test.response) + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Origin != "mcp" || + apiErr.FailureStage != "response_validation" || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+test.tool || !apiErr.RetryableSet || apiErr.Retryable { + t.Fatalf("protocol failure error = %#v", err) + } + if test.wantLogID != "" && !strings.Contains(err.Error(), "logId="+test.wantLogID) { + t.Fatalf("protocol failure error = %v, want backend logId", err) + } + }) + } +} + +func TestCrossPlatformCoverageDecodeSheetChangesetTransportRejectsBusinessFailure(t *testing.T) { + const response = `{"success":false,"logId":"trace","errorCode":"UPSTREAM","errorMsg":"failed"}` + _, err := decodeSheetRevisionResult(sheetChangesetGetRemoteTool, + map[string]any{"startRevision": int64(0)}, response) + var cliErr *CLIError + if !errors.As(err, &cliErr) || cliErr.Code != CodeMCPToolError || + cliErr.Operation != "sheet/"+sheetChangesetGetRemoteTool { + t.Fatalf("decode failure error = %#v, want sheet MCP business failure", err) + } + if !strings.Contains(cliErr.Message, `"errorCode":"UPSTREAM"`) { + t.Fatalf("business failure message = %q, want backend error payload", cliErr.Message) + } +} + +func TestCrossPlatformCoverageSheetRevisionCommandsRejectInvalidResponsesWithoutSuccessEnvelope(t *testing.T) { + tests := []struct { + name string + tool string + response string + build func() *cobra.Command + args []string + }{ + { + name: "revision empty response", tool: sheetRevisionGetRemoteTool, response: " \n ", + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset empty response", tool: sheetChangesetGetRemoteTool, response: "", + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "revision non-object response", tool: sheetRevisionGetRemoteTool, response: `[]`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset missing success", tool: sheetChangesetGetRemoteTool, response: `{"changesetsJson":"[]"}`, + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "revision business failure", tool: sheetRevisionGetRemoteTool, + response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset business failure", tool: sheetChangesetGetRemoteTool, + response: `{"success":false,"errorCode":"UPSTREAM","errorMsg":"failed"}`, + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}} + got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...) + if err == nil { + t.Fatalf("command unexpectedly succeeded with output %q", got) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("failure path emitted a success envelope: %s", got) + } + }) + } +} + +func TestCrossPlatformCoverageSheetRevisionCommandsRejectPublishedResultContractDrift(t *testing.T) { + validSummary := `"summary":{"changeCount":0,"completeChangeCount":0,"partialChangeCount":0,"unsupportedChangeCount":0,"containsStateReset":false,"containsIncompleteChanges":false,"affectedSheets":[]}` + validChangesetPrefix := `"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,` + validChangesets := `"changesets":[]` + + tests := []struct { + name string + tool string + response string + build func() *cobra.Command + args []string + }{ + { + name: "revision missing required field", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "revision wrong field type", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract","revision":"1"}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "revision empty log id", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":" ","revision":1}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "revision negative integer", tool: sheetRevisionGetRemoteTool, + response: `{"success":true,"logId":"trace-contract","revision":-1}`, + build: newSheetRevisionGetCmd, args: []string{"--node", "node-1"}, + }, + { + name: "changeset missing summary", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong revision type", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":"1","startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong schema version", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":3,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset wrong semantics", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"BIDIRECTIONAL","latestRevision":1,"startRevision":0,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset malformed summary", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+`"summary":{"changeCount":0},`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset negative latest revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":-1,"startRevision":0,"endRevision":0,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset negative start revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":-1,"endRevision":0,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset negative end revision", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":-1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset start after end", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":2,"startRevision":2,"endRevision":1,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset end after latest", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{"success":true,"logId":"trace-contract","schemaVersion":2,"changeSemantics":"FORWARD_ONLY","latestRevision":1,"startRevision":0,"endRevision":2,`+validSummary+`,`+validChangesets+`}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + { + name: "changeset item missing required field", tool: sheetChangesetGetRemoteTool, + response: encodeSheetChangesetTransport(t, `{`+validChangesetPrefix+validSummary+`,"changesets":[{"revision":1,"isSelfEdit":true,"eventType":"EDIT","detailsStatus":"COMPLETE","changes":[]}]}`), + build: newSheetChangesetGetCmd, args: []string{"--node", "node-1", "--start-revision", "0"}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{test.tool: test.response}} + got, err := executeSheetRevisionCommand(t, caller, test.build(), test.args...) + if err == nil { + t.Fatalf("command unexpectedly accepted contract drift with output %q", got) + } + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "invalid_tool_response" || + apiErr.Operation != "sheet/"+test.tool || apiErr.FailureStage != "response_validation" { + t.Fatalf("contract drift error = %#v", err) + } + if strings.TrimSpace(got) != "" { + t.Fatalf("contract drift emitted a success envelope: %s", got) + } + }) + } +} + +func TestCrossPlatformCoverageValidateSheetPublishedResultCoversDefensiveFailures(t *testing.T) { + if err := validateSheetPublishedResult("unknown-tool", map[string]any{}); err == nil { + t.Fatal("unknown tool unexpectedly accepted") + } + + cache := &sheetPublishedResultSchemaCache{} + if err := validateSheetPublishedResultWithSchema(sheetRevisionGetRemoteTool, map[string]any{}, + json.RawMessage(`{`), cache); err == nil { + t.Fatal("invalid published schema unexpectedly accepted") + } + + if _, err := sheetNonNegativeInteger(int64(1)); err == nil { + t.Fatal("non-json number unexpectedly accepted") + } +} + +func TestCrossPlatformCoverageDecodeSheetRevisionEmptyResponseHasStructuredRetryableError(t *testing.T) { + _, err := decodeSheetRevisionResult(sheetRevisionGetRemoteTool, nil, " \n ") + var apiErr *apperrors.Error + if !errors.As(err, &apiErr) || apiErr.Reason != "empty_tool_response" || + apiErr.Operation != "sheet/"+sheetRevisionGetRemoteTool || + apiErr.FailureStage != "response_validation" || !apiErr.RetryableSet || !apiErr.Retryable { + t.Fatalf("empty response error = %#v", err) + } +} + +func TestCrossPlatformCoverageNormalizeSheetChangesetTransportRejectsOversizedString(t *testing.T) { + data := map[string]any{ + "success": true, + "changesetsJson": strings.Repeat("x", sheetChangesetJSONMaxBytes+1), + } + if err := normalizeSheetChangesetTransport(data); err == nil || !strings.Contains(err.Error(), "超过") { + t.Fatalf("oversized changesetsJson error = %v", err) + } +} + +func TestCrossPlatformCoverageSheetChangesetGetDryRunPreviewsWithoutCallingMCP(t *testing.T) { + caller := &sheetRevisionTestCaller{dryRun: true, responses: map[string]string{}} + got, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), + "--node", "node-1", "--start-revision", "0", "--dry-run") + if err != nil { + t.Fatalf("changeset-get dry-run returned error: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run dispatched MCP calls: %#v", caller.calls) + } + var envelope map[string]any + if err := json.Unmarshal([]byte(got), &envelope); err != nil { + t.Fatalf("decode dry-run output: %v\n%s", err, got) + } + if envelope["dry_run"] != true || envelope["outcome"] != "success" { + t.Fatalf("dry-run envelope = %#v", envelope) + } + data, ok := envelope["data"].(map[string]any) + if !ok || data["executed"] != false || data["tool"] != sheetChangesetGetRemoteTool { + t.Fatalf("dry-run data = %#v", envelope["data"]) + } + arguments, ok := data["arguments"].(map[string]any) + if !ok || arguments["startRevision"] != float64(0) || arguments["nodeId"] != "node-1" { + t.Fatalf("dry-run arguments = %#v", data["arguments"]) + } +} + +func TestCrossPlatformCoverageSheetChangesetGetRejectsInvalidRangesBeforeDispatch(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + {name: "missing node", args: []string{"--start-revision", "0"}, want: "--node"}, + {name: "missing start", args: []string{"--node", "node-1"}, want: "--start-revision"}, + {name: "non integer start", args: []string{"--node", "node-1", "--start-revision", "1.5"}, want: "--start-revision 必须是 64 位整数"}, + {name: "non integer end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "1.5"}, want: "--end-revision 必须是 64 位整数"}, + {name: "negative start", args: []string{"--node", "node-1", "--start-revision", "-1"}, want: "--start-revision 必须是非负整数"}, + {name: "negative end", args: []string{"--node", "node-1", "--start-revision", "0", "--end-revision", "-1"}, want: "--end-revision 必须是非负整数"}, + {name: "end before start", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "8"}, want: "必须大于或等于"}, + {name: "span too large", args: []string{"--node", "node-1", "--start-revision", "9", "--end-revision", "30"}, want: "单次最多查询 20 个 revision"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + caller := &sheetRevisionTestCaller{responses: map[string]string{}} + _, err := executeSheetRevisionCommand(t, caller, newSheetChangesetGetCmd(), test.args...) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want containing %q", err, test.want) + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input dispatched calls: %#v", caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageSheetRevisionCommandsPublishHelpAndReviewedContracts(t *testing.T) { + root := newSheetCommand() + for _, name := range []string{"revision-get", "changeset-get"} { + command, _, err := root.Find([]string{name}) + if err != nil || command == nil || command.Name() != name { + t.Fatalf("sheet %s lookup = command %#v, err %v", name, command, err) + } + if command.Flags().Lookup("sheet-id") != nil { + t.Fatalf("sheet %s unexpectedly accepts --sheet-id", name) + } + if !strings.Contains(command.Long, "工作簿级") { + t.Fatalf("sheet %s Long = %q, want workbook-level scope", name, command.Long) + } + + final, ok := contractfinal.RuntimeContractFinal(command) + if !ok { + t.Fatalf("sheet %s missing ContractFinal", name) + } + if final.Safety == nil || final.Safety.Effect != "read" || final.Safety.Confirmation != "not_required" { + t.Fatalf("sheet %s safety = %#v", name, final.Safety) + } + if final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.ProductID != "sheet" { + t.Fatalf("sheet %s interface = %#v", name, final.Interface) + } + if final.Result == nil || len(final.Result.DataSchema) == 0 { + t.Fatalf("sheet %s result = %#v", name, final.Result) + } + if final.DryRun == nil || final.DryRun.PreviewKind != contract.DryRunPreviewRequest { + t.Fatalf("sheet %s dry-run = %#v, want request preview", name, final.DryRun) + } + if output.CommandRollout(command) != output.RolloutUnifiedActive { + t.Fatalf("sheet %s rollout = %s, want unified_active so Result is Agent-visible", name, output.CommandRollout(command)) + } + if final.Selection == nil || len(final.Selection.UseWhen) == 0 || len(final.Selection.AvoidWhen) == 0 { + t.Fatalf("sheet %s selection = %#v", name, final.Selection) + } + } + + changeset, _, _ := root.Find([]string{"changeset-get"}) + if !strings.Contains(changeset.Long, "(startRevision, endRevision]") || + !strings.Contains(changeset.Long, "data.changesets") || + !strings.Contains(changeset.Long, "JSON 数组") || + !strings.Contains(changeset.Long, "只解码服务端传输格式") || + !strings.Contains(changeset.Long, "前向") || !strings.Contains(changeset.Long, "old/current") || + !strings.Contains(changeset.Long, "最终状态必须另行回读") { + t.Fatalf("changeset help does not explain interval/decoded output: %q", changeset.Long) + } + final, _ := contractfinal.RuntimeContractFinal(changeset) + if len(final.Parameters) != 3 || final.Parameters[1].InterfaceType != "number" || final.Parameters[2].InterfaceType != "number" { + t.Fatalf("changeset parameters = %#v, want numeric revision interface types", final.Parameters) + } +} + +func TestCrossPlatformCoverageSheetRevisionResultSchemasUseMCPNumberAndChangesetV2(t *testing.T) { + var revisionSchema map[string]any + if err := json.Unmarshal(sheetRevisionResult.DataSchema, &revisionSchema); err != nil { + t.Fatalf("decode revision Result schema: %v", err) + } + revisionProperties := mustSheetSchemaMap(t, revisionSchema, "properties") + for _, name := range []string{"success", "logId", "revision"} { + mustSheetSchemaMap(t, revisionProperties, name) + } + for _, name := range []string{"success", "logId", "revision"} { + assertSheetSchemaRequired(t, revisionSchema, name) + } + if got := mustSheetSchemaMap(t, revisionProperties, "revision")["type"]; got != "number" { + t.Fatalf("revision schema type = %#v, want MCP number", got) + } + + var changesetSchema map[string]any + if err := json.Unmarshal(sheetChangesetResult.DataSchema, &changesetSchema); err != nil { + t.Fatalf("decode changeset Result schema: %v", err) + } + properties := mustSheetSchemaMap(t, changesetSchema, "properties") + for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} { + mustSheetSchemaMap(t, properties, name) + } + for _, name := range []string{"success", "logId", "schemaVersion", "changeSemantics", "latestRevision", "startRevision", "endRevision", "summary", "changesets"} { + assertSheetSchemaRequired(t, changesetSchema, name) + } + for _, name := range []string{"schemaVersion", "latestRevision", "startRevision", "endRevision"} { + if got := mustSheetSchemaMap(t, properties, name)["type"]; got != "number" { + t.Fatalf("changeset %s schema type = %#v, want MCP number", name, got) + } + } + + changesetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, properties, "changesets")) + changesetProperties := mustSheetSchemaMap(t, changesetItem, "properties") + for _, name := range []string{"revision", "createTime", "isSelfEdit", "eventType", "detailsStatus", "reset", "changes"} { + mustSheetSchemaMap(t, changesetProperties, name) + } + assertSheetSchemaNotRequired(t, changesetItem, "reset") + if got := mustSheetSchemaMap(t, changesetProperties, "reset")["type"]; got != "object" { + t.Fatalf("changeset reset schema type = %#v, want optional object", got) + } + changeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changesetProperties, "changes")) + changeProperties := mustSheetSchemaMap(t, changeItem, "properties") + for _, name := range []string{"type", "targets", "details", "detailsStatus", "omissions"} { + mustSheetSchemaMap(t, changeProperties, name) + assertSheetSchemaRequired(t, changeItem, name) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, changeProperties, "type"), 40, "UNSUPPORTED_CHANGE") + detailsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, changeProperties, "details"), "properties") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "fillMode"), 5, "predict") + if got := mustSheetSchemaMap(t, detailsProperties, "copyStyle")["type"]; got != "boolean" { + t.Fatalf("copyStyle schema type = %#v, want boolean", got) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "styleMode"), 4, "cell") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "pasteMode"), 4, "sheet") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, detailsProperties, "iterateMode"), 2, "flex") + if got := mustSheetSchemaMap(t, detailsProperties, "isCut")["type"]; got != "boolean" { + t.Fatalf("isCut schema type = %#v, want boolean", got) + } + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "includedParts"), "items"), 18, "CONTENT") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "clearParts"), "items"), 14, "CELL_TYPES") + assertSheetSchemaEnum(t, mustSheetSchemaMap(t, + mustSheetSchemaMap(t, detailsProperties, "preservedCellTypes"), "items"), 2, "SELECT") + for _, name := range []string{"cell", "cellType", "tag", "border", "style", "properties", "changes"} { + objectProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, name), "properties") + mustSheetSchemaMap(t, objectProperties, "cleared") + } + cellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "cell"), "properties") + if got := mustSheetSchemaMap(t, cellProperties, "formula")["type"]; got != "string" { + t.Fatalf("cell formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, cellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, cellProperties, "value"), false) + cellLink := mustSheetSchemaMap(t, cellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, cellLink, "properties"), name) + } + if required, exists := cellLink["required"]; exists { + t.Fatalf("cell.link schema required = %#v, want clear marker to be valid without range fields", required) + } + relativeChangeItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, detailsProperties, "relativeChanges")) + relativeChangeProperties := mustSheetSchemaMap(t, relativeChangeItem, "properties") + if got := mustSheetSchemaMap(t, relativeChangeProperties, "offset")["type"]; got != "number" { + t.Fatalf("relativeChanges offset schema type = %#v, want MCP number", got) + } + relativeDimensionProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "properties"), "properties") + for _, name := range []string{"cleared", "size", "customSize", "hidden", "sticky", "cellType"} { + mustSheetSchemaMap(t, relativeDimensionProperties, name) + } + for _, name := range []string{"size", "customSize", "hidden", "sticky"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, relativeDimensionProperties, name), "null") + } + if required, exists := relativeChangeItem["required"]; exists { + t.Fatalf("relativeChanges item required = %#v, want refs.sheet item valid without offsets", required) + } + mustSheetSchemaMap(t, relativeChangeProperties, "styleCleared") + mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "tag"), "properties") + relativeCellProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeChangeProperties, "cell"), "properties") + if got := mustSheetSchemaMap(t, relativeCellProperties, "formula")["type"]; got != "string" { + t.Fatalf("relative cell formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, relativeCellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, relativeCellProperties, "value"), false) + relativeCellLink := mustSheetSchemaMap(t, relativeCellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, relativeCellLink, "properties"), name) + } + contentPatternProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "contentPattern"), "properties") + contentPatternCell := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, contentPatternProperties, "cells")) + contentPatternCellProperties := mustSheetSchemaMap(t, contentPatternCell, "properties") + mustSheetSchemaMap(t, contentPatternCellProperties, "cleared") + if got := mustSheetSchemaMap(t, contentPatternCellProperties, "formula")["type"]; got != "string" { + t.Fatalf("content pattern formula schema type = %#v, want string", got) + } + mustSheetSchemaMap(t, contentPatternCellProperties, "formulaCleared") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, contentPatternCellProperties, "value"), false) + contentPatternLink := mustSheetSchemaMap(t, contentPatternCellProperties, "link") + for _, name := range []string{"cleared", "type", "sheetId", "a1Range", "absolute"} { + mustSheetSchemaMap(t, mustSheetSchemaMap(t, contentPatternLink, "properties"), name) + } + if required, exists := contentPatternLink["required"]; exists { + t.Fatalf("contentPattern cell link schema required = %#v, want clear marker to be valid", required) + } + styles := mustSheetSchemaMap(t, detailsProperties, "styles") + if got := styles["type"]; got != "array" { + t.Fatalf("styles schema type = %#v, want named entry array", got) + } + styleEntry := mustSheetSchemaArrayItem(t, styles) + styleEntryProperties := mustSheetSchemaMap(t, styleEntry, "properties") + mustSheetSchemaMap(t, styleEntryProperties, "styleId") + mustSheetSchemaMap(t, styleEntryProperties, "style") + assertSheetSchemaRequired(t, styleEntry, "styleId") + assertSheetSchemaRequired(t, styleEntry, "style") + dataValidationProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "dataValidation"), "properties") + if got := mustSheetSchemaMap(t, dataValidationProperties, "templateId")["type"]; got != "number" { + t.Fatalf("dataValidation templateId schema type = %#v, want MCP number", got) + } + if got := mustSheetSchemaMap(t, dataValidationProperties, "options")["type"]; got != "array" { + t.Fatalf("dataValidation options schema type = %#v, want dropdown array", got) + } + settingsProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "settings"), "properties") + for _, name := range []string{"allowBlank", "errorStyle", "showInputMessage", "showErrorMessage", "prompt", "error", "requiredInRow", "showDropDown", "columnType", "promptTitle", "errorTitle", "imeMode"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingsProperties, name), "null") + } + criteriaProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, dataValidationProperties, "criteria"), "properties") + mustSheetSchemaMap(t, criteriaProperties, "operator") + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value1"), true) + assertSheetTypedValueSchema(t, mustSheetSchemaMap(t, criteriaProperties, "value2"), true) + mustSheetSchemaMap(t, criteriaProperties, "formula") + settingChangesProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, detailsProperties, "changes"), "properties") + settingMode := mustSheetSchemaMap(t, settingChangesProperties, "mode") + assertSheetSchemaEnum(t, settingMode, 4, "default") + if got := settingMode["type"]; got != "string" { + t.Fatalf("setting mode schema type = %#v, want string", got) + } + for _, name := range []string{"cleared", "iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904", "image"} { + mustSheetSchemaMap(t, settingChangesProperties, name) + } + for _, name := range []string{"iterate", "iterateCount", "iterateDelta", "enableDynamicArray", "date1904"} { + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingChangesProperties, name), "null") + } + settingImageProperties := mustSheetSchemaMap(t, mustSheetSchemaMap(t, settingChangesProperties, "image"), "properties") + mustSheetSchemaMap(t, settingImageProperties, "cleared") + assertSheetSchemaTypeIncludes(t, mustSheetSchemaMap(t, settingImageProperties, "opacity"), "null") + omissionItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "omissions")) + omissionCode := mustSheetSchemaMap(t, mustSheetSchemaMap(t, omissionItem, "properties"), "code") + assertSheetSchemaEnum(t, omissionCode, 13, "SOURCE_RANGE_UNRESOLVED") + targetItem := mustSheetSchemaArrayItem(t, mustSheetSchemaMap(t, changeProperties, "targets")) + targetProperties := mustSheetSchemaMap(t, targetItem, "properties") + for _, name := range []string{"scope", "sheetId", "sheetName", "sheetNameSource", "a1Range", "role"} { + mustSheetSchemaMap(t, targetProperties, name) + } + + for _, forbidden := range []string{ + `"actions"`, `"operationKind"`, `"operationVersion"`, `"action"`, `"payload"`, + `"understood"`, `"payloadOmitted"`, `"sourceOperationIndex"`, `"pluginVersion"`, + `"targetId"`, `"targetType"`, `"changeKind"`, `"sourceDeltaType"`, `"resetType"`, + `"resetTarget"`, `"targetAvailable"`, `"warnings"`, `"changesetsJson"`, + } { + if bytes.Contains(sheetChangesetResult.DataSchema, []byte(forbidden)) { + t.Fatalf("changeset Result schema still exposes forbidden V1 key %s", forbidden) + } + } +} + +func assertSheetTypedValueSchema(t *testing.T, schema map[string]any, allowFormula bool) { + t.Helper() + if got := schema["type"]; got != "object" { + t.Fatalf("typed value schema type = %#v, want object", got) + } + properties := mustSheetSchemaMap(t, schema, "properties") + for _, name := range []string{"kind", "stringValue", "numberValue", "booleanValue"} { + mustSheetSchemaMap(t, properties, name) + } + if allowFormula { + mustSheetSchemaMap(t, properties, "formula") + } else { + mustSheetSchemaMap(t, properties, "values") + } + assertSheetSchemaRequired(t, schema, "kind") +} + +func mustSheetSchemaMap(t *testing.T, parent map[string]any, key string) map[string]any { + t.Helper() + value, ok := parent[key].(map[string]any) + if !ok { + t.Fatalf("schema key %q = %#v, want object", key, parent[key]) + } + return value +} + +func mustSheetSchemaArrayItem(t *testing.T, schema map[string]any) map[string]any { + t.Helper() + return mustSheetSchemaMap(t, schema, "items") +} + +func assertSheetSchemaRequired(t *testing.T, schema map[string]any, name string) { + t.Helper() + required, ok := schema["required"].([]any) + if !ok { + t.Fatalf("schema required = %#v, want array containing %q", schema["required"], name) + } + for _, value := range required { + if value == name { + return + } + } + t.Fatalf("schema required = %#v, missing %q", required, name) +} + +func assertSheetSchemaNotRequired(t *testing.T, schema map[string]any, name string) { + t.Helper() + required, ok := schema["required"].([]any) + if !ok { + t.Fatalf("schema required = %#v, want array without %q", schema["required"], name) + } + for _, value := range required { + if value == name { + t.Fatalf("schema required = %#v, unexpectedly contains optional %q", required, name) + } + } +} + +func assertSheetSchemaEnum(t *testing.T, schema map[string]any, wantCount int, wantValue string) { + t.Helper() + values, ok := schema["enum"].([]any) + if !ok || len(values) != wantCount { + t.Fatalf("schema enum = %#v, want %d values including %q", schema["enum"], wantCount, wantValue) + } + for _, value := range values { + if value == wantValue { + return + } + } + t.Fatalf("schema enum = %#v, missing %q", values, wantValue) +} + +func assertSheetSchemaTypeIncludes(t *testing.T, schema map[string]any, want string) { + t.Helper() + types, ok := schema["type"].([]any) + if !ok { + t.Fatalf("schema type = %#v, want array containing %q", schema["type"], want) + } + for _, value := range types { + if value == want { + return + } + } + t.Fatalf("schema type = %#v, missing %q", types, want) +} diff --git a/internal/helpers/sheet_version.go b/internal/helpers/sheet_version.go index 58e00bfb..a1a8c7c4 100644 --- a/internal/helpers/sheet_version.go +++ b/internal/helpers/sheet_version.go @@ -111,9 +111,14 @@ func newSheetVersionCmd() *cobra.Command { versionListCmd.Flags().String("cursor", "", "分页游标") versionRevertCmd := &cobra.Command{ - Use: "revert", - Short: "[危险] 回滚表格到指定版本", - Example: ` dws sheet version revert --node SHEET_ID --version 3 --yes`, + Use: "revert", + Short: "[危险] 回滚表格到指定历史版本或 revision", + Long: `将在线表格恢复到指定历史版本或精确 revision。 + +通常应从 version list 选择已保存的历史版本。用户明确要求恢复到某个精确 revision 时, +也可传入已从同一工作簿真实查询结果确认的 revision,即使它不在版本列表中。未列入 +版本列表的 revision 只有在服务端仍可恢复时才能成功;禁止猜测 revision。`, + Example: ` dws sheet version revert --node SHEET_ID --version 3`, RunE: func(cmd *cobra.Command, args []string) error { nodeID, err := mustFlagOrFallback(cmd, "node", "url", "id", "node-id", "doc-id", "file-id") if err != nil { @@ -142,22 +147,22 @@ func newSheetVersionCmd() *cobra.Command { CLIPath: "sheet version revert", PrimaryCLIPath: "sheet version revert", }, - Description: "回滚表格到指定历史版本", + Description: "回滚表格到指定历史版本或已确认的精确 revision", Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", Reason: "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.", }, Selection: contract.SelectionSpec{ - AgentSummary: "回滚表格到指定历史版本", - UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格"}, - AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert"}, + AgentSummary: "回滚表格到指定历史版本或已确认的精确 revision", + UseWhen: []string{"用户说 回滚到某个版本/恢复到之前的表格,或明确要求恢复到同一工作簿中已确认的 revision"}, + AvoidWhen: []string{"普通文件回滚用 drive revert;在线文档用 doc version revert;目标 revision 未经同一工作簿的真实查询结果确认时不要猜测"}, Examples: []string{"dws sheet version revert --node --version 3 --format json"}, }, }, }) versionRevertCmd.Flags().String("node", "", "表格文档 ID 或 URL (必填)") - versionRevertCmd.Flags().Int("version", 0, "目标版本号 (必填,从 list 获取)") + versionRevertCmd.Flags().Int("version", 0, "目标历史版本或已确认 revision (必填,通常从 version list 获取)") for _, c := range []*cobra.Command{versionSaveCmd, versionListCmd, versionRevertCmd} { c.Flags().String("url", "", "") diff --git a/internal/helpers/sheet_version_test.go b/internal/helpers/sheet_version_test.go new file mode 100644 index 00000000..3288f64b --- /dev/null +++ b/internal/helpers/sheet_version_test.go @@ -0,0 +1,78 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package helpers + +import ( + "bytes" + "reflect" + "strings" + "testing" +) + +func TestSheetVersionRevertHelpDocumentsConfirmedRevisionTargets(t *testing.T) { + command := newSheetVersionCmd() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs([]string{"revert", "--help"}) + + if err := command.Execute(); err != nil { + t.Fatalf("sheet version revert --help returned error: %v", err) + } + help := output.String() + for _, expected := range []string{ + "通常应从 version list 选择已保存的历史版本", + "已从同一工作簿真实查询结果确认的 revision", + "禁止猜测 revision", + "目标历史版本或已确认 revision", + } { + if !strings.Contains(help, expected) { + t.Fatalf("sheet version revert help missing %q:\n%s", expected, help) + } + } + if strings.Contains(help, "--yes") { + t.Fatalf("sheet version revert help must not publish a confirmation-bypass example:\n%s", help) + } +} + +func TestSheetVersionRevertRequiresConfirmationAndCallsExactToolWhenConfirmed(t *testing.T) { + args := []string{"version", "revert", "--node", "node-1", "--version", "37"} + + caller := &guardedMutationCaller{} + err := executeGuardedMutationCommand(t, caller, newSheetCommand, args...) + requireTypedConfirmationError(t, err) + if len(caller.calls) != 0 { + t.Fatalf("expected 0 MCP calls before confirmation, got %d: %+v", len(caller.calls), caller.calls) + } + + caller = &guardedMutationCaller{} + confirmedArgs := append(append([]string(nil), args...), "--yes") + if err := executeGuardedMutationCommand(t, caller, newSheetCommand, confirmedArgs...); err != nil { + t.Fatalf("sheet version revert after confirmation returned error: %v", err) + } + want := guardedMutationCall{ + productID: "doc", + toolName: "revert_doc_version", + args: map[string]any{ + "nodeId": "node-1", + "version": 37, + }, + } + if len(caller.calls) != 1 { + t.Fatalf("expected exactly 1 MCP call after confirmation, got %d: %+v", len(caller.calls), caller.calls) + } + if !reflect.DeepEqual(caller.calls[0], want) { + t.Fatalf("tool call = %#v, want %#v", caller.calls[0], want) + } +} diff --git a/internal/helpers/wiki.go b/internal/helpers/wiki.go index 3c8dc9cf..8d3bac3e 100644 --- a/internal/helpers/wiki.go +++ b/internal/helpers/wiki.go @@ -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, }) }, diff --git a/internal/helpers/wiki_coverage_more_test.go b/internal/helpers/wiki_coverage_more_test.go index 9ebd7561..446008f2 100644 --- a/internal/helpers/wiki_coverage_more_test.go +++ b/internal/helpers/wiki_coverage_more_test.go @@ -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 }) diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 81870ac1..558ffc34 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -80,6 +80,7 @@ "工具调用失败;请检查参数和上游服务状态。": "Tool invocation failed; check parameters and upstream service status.", "已取消操作": "Operation cancelled", "当前平台 %s 没有可用的预编译二进制": "No pre-built binary available for platform %s", + "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "The current identity's refresh_token is invalid; login state was recovered from the organization mirror token.", "待办": "todo", "待办任务 ID (必填)": "Todo task ID (required)", "待办任务管理": "Todo task management", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 06b04ac5..29cc29d2 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -80,6 +80,7 @@ "工具调用失败;请检查参数和上游服务状态。": "工具调用失败;请检查参数和上游服务状态。", "已取消操作": "已取消操作", "当前平台 %s 没有可用的预编译二进制": "当前平台 %s 没有可用的预编译二进制", + "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态": "当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态", "待办": "待办", "待办任务 ID (必填)": "待办任务 ID (必填)", "待办任务管理": "待办任务管理", diff --git a/internal/interfacesnapshot/command_migrations.go b/internal/interfacesnapshot/command_migrations.go new file mode 100644 index 00000000..62205fba --- /dev/null +++ b/internal/interfacesnapshot/command_migrations.go @@ -0,0 +1,639 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "reflect" + "strconv" + "strings" +) + +const CommandMigrationManifestVersion = 1 + +const ( + CommandMigrationPending = "pending" + CommandMigrationConsumed = "consumed" + + CommandMigrationMove = "command_move" + CommandMigrationFlagExtraction = "flag_extraction" +) + +// CommandMigrationManifest governs compatibility-preserving surface moves that +// cannot be represented as an in-command flag rename. The merge-base owns the +// authorization; the candidate copy is only a lifecycle receipt. +type CommandMigrationManifest struct { + Version int `json:"version"` + Migrations []CommandMigration `json:"migrations"` +} + +type CommandMigration struct { + Kind string `json:"kind"` + Legacy CommandMigrationSide `json:"legacy"` + Replacement CommandMigrationSide `json:"replacement"` + LegacyFlag CommandMigrationFlag `json:"legacy_flag"` + Schema CommandMigrationSchema `json:"schema"` + State string `json:"state"` + Reason string `json:"reason"` +} + +type CommandMigrationSide struct { + Command string `json:"command"` + Before CommandMigrationState `json:"before"` + After CommandMigrationState `json:"after"` +} + +type CommandMigrationState struct { + Present bool `json:"present"` + Runnable bool `json:"runnable,omitempty"` + Hidden bool `json:"hidden,omitempty"` +} + +type CommandMigrationFlag struct { + Name string `json:"name,omitempty"` + Before FlagMigrationState `json:"before"` + After FlagMigrationState `json:"after"` +} + +type CommandMigrationSchema struct { + ProductID string `json:"product_id"` + SourceToolID string `json:"source_tool_id"` + ReplacementToolID string `json:"replacement_tool_id"` + Parameters []CommandParameterMigration `json:"parameters"` +} + +type CommandParameterMigration struct { + From string `json:"from"` + To string `json:"to,omitempty"` + ReplacementConstant *CommandReplacementConstant `json:"replacement_constant,omitempty"` +} + +type CommandReplacementConstant struct { + Property string `json:"property"` + Value bool `json:"value"` +} + +func ReadCommandMigrationManifest(r io.Reader) (CommandMigrationManifest, error) { + data, err := io.ReadAll(r) + if err != nil { + return CommandMigrationManifest{}, fmt.Errorf("read command migration manifest: %w", err) + } + var manifest CommandMigrationManifest + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return CommandMigrationManifest{}, err + } + if err := decoder.Decode(&struct{}{}); err != io.EOF { + if err == nil { + return CommandMigrationManifest{}, fmt.Errorf("command migration manifest contains trailing multiple JSON values") + } + return CommandMigrationManifest{}, fmt.Errorf("read trailing command migration manifest data: %w", err) + } + strict := json.NewDecoder(bytes.NewReader(data)) + strict.UseNumber() + if err := validateLabeledMigrationJSONValue(strict, "$", reflect.TypeOf(CommandMigrationManifest{}), "command"); err != nil { + return CommandMigrationManifest{}, err + } + if err := manifest.Validate(); err != nil { + return CommandMigrationManifest{}, err + } + return manifest, nil +} + +func (m CommandMigrationManifest) Validate() error { + if m.Version != CommandMigrationManifestVersion { + return fmt.Errorf("unsupported command migration manifest version %d (want %d)", m.Version, CommandMigrationManifestVersion) + } + if m.Migrations == nil { + return fmt.Errorf("command migration manifest migrations must be an array") + } + seen := make(map[string]bool, len(m.Migrations)) + for index, migration := range m.Migrations { + if err := migration.validate(); err != nil { + return fmt.Errorf("command migration %d: %w", index, err) + } + if seen[migration.key()] { + return fmt.Errorf("command migration %d duplicates %s", index, migration.displayKey()) + } + seen[migration.key()] = true + } + return nil +} + +func (m CommandMigration) validate() error { + if m.Kind != CommandMigrationMove && m.Kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid kind %q", m.Kind) + } + if !isExactCommandPath(m.Legacy.Command) || !isExactCommandPath(m.Replacement.Command) { + return fmt.Errorf("legacy and replacement must be exact command paths rooted at dws") + } + if m.Legacy.Command == m.Replacement.Command { + return fmt.Errorf("legacy and replacement command paths must differ") + } + if strings.TrimSpace(m.Reason) == "" || m.Reason != strings.TrimSpace(m.Reason) { + return fmt.Errorf("migration must include a non-empty trimmed reason") + } + if m.State != CommandMigrationPending && m.State != CommandMigrationConsumed { + return fmt.Errorf("invalid state %q", m.State) + } + for label, state := range map[string]CommandMigrationState{ + "legacy before": m.Legacy.Before, + "legacy after": m.Legacy.After, + "replacement before": m.Replacement.Before, + "replacement after": m.Replacement.After, + } { + if err := state.validate(label); err != nil { + return err + } + } + if !m.Legacy.Before.Present || !m.Legacy.Before.Runnable || m.Legacy.Before.Hidden || + !m.Legacy.After.Present || !m.Legacy.After.Runnable { + return fmt.Errorf("legacy command must remain runnable and start visible") + } + if m.Replacement.Before.Present || !m.Replacement.After.Present || !m.Replacement.After.Runnable || m.Replacement.After.Hidden { + return fmt.Errorf("replacement command must migrate exactly from absent to visible runnable") + } + if err := m.Schema.validate(m.Kind); err != nil { + return err + } + switch m.Kind { + case CommandMigrationMove: + if strings.HasPrefix(m.Replacement.Command, m.Legacy.Command+" ") || + strings.HasPrefix(m.Legacy.Command, m.Replacement.Command+" ") { + return fmt.Errorf("command_move legacy and replacement command paths must not have an ancestor relationship") + } + if !m.Legacy.After.Hidden { + return fmt.Errorf("command_move legacy command must migrate exactly from visible to hidden") + } + if m.LegacyFlag != (CommandMigrationFlag{}) { + return fmt.Errorf("command_move must not declare legacy_flag") + } + if m.Schema.SourceToolID != m.Schema.ReplacementToolID { + return fmt.Errorf("command_move must retain one stable Schema tool identity") + } + case CommandMigrationFlagExtraction: + if m.Legacy.After.Hidden || m.Legacy.Before != m.Legacy.After { + return fmt.Errorf("flag_extraction legacy command must remain visible and unchanged") + } + if err := m.LegacyFlag.validate(); err != nil { + return err + } + if m.Schema.SourceToolID == m.Schema.ReplacementToolID { + return fmt.Errorf("flag_extraction requires a distinct replacement Schema tool") + } + if m.LegacyFlag.Before.Type != "bool" || m.LegacyFlag.After.Type != "bool" { + return fmt.Errorf("flag_extraction legacy_flag must be an optional bool") + } + var extracted *CommandParameterMigration + constantCount := 0 + for index := range m.Schema.Parameters { + parameter := &m.Schema.Parameters[index] + if parameter.ReplacementConstant == nil { + continue + } + constantCount++ + if parameter.From == m.LegacyFlag.Name { + extracted = parameter + } + } + if constantCount != 1 { + return fmt.Errorf("flag_extraction must declare exactly one replacement constant") + } + if extracted == nil { + return fmt.Errorf("flag_extraction must map the exact extracted flag to replacement_constant") + } + if !extracted.ReplacementConstant.Value { + return fmt.Errorf("flag_extraction v1 replacement_constant must be true") + } + wantNoOpt := strconv.FormatBool(extracted.ReplacementConstant.Value) + if m.LegacyFlag.Before.NoOpt != wantNoOpt || m.LegacyFlag.After.NoOpt != wantNoOpt { + return fmt.Errorf("flag_extraction legacy_flag no_opt must equal replacement constant %q", wantNoOpt) + } + } + return nil +} + +func (s CommandMigrationState) validate(label string) error { + if !s.Present { + if s.Runnable || s.Hidden { + return fmt.Errorf("%s absent state must not declare command attributes", label) + } + return nil + } + if !s.Runnable { + return fmt.Errorf("%s present state must be runnable", label) + } + return nil +} + +func (f CommandMigrationFlag) validate() error { + if !isExactFlagName(f.Name) { + return fmt.Errorf("legacy_flag name must be an exact flag") + } + if err := f.Before.validate("legacy_flag before"); err != nil { + return err + } + if err := f.After.validate("legacy_flag after"); err != nil { + return err + } + if !f.Before.Present || !f.After.Present || f.Before.Hidden || !f.After.Hidden { + return fmt.Errorf("legacy_flag must migrate exactly from visible to hidden while remaining present") + } + if f.Before.Required || f.After.Required { + return fmt.Errorf("legacy_flag must remain optional before and after extraction") + } + before := f.Before + after := f.After + before.Hidden = false + after.Hidden = false + if before != after { + return fmt.Errorf("legacy_flag may change only hidden visibility") + } + return nil +} + +func (s CommandMigrationSchema) validate(kind string) error { + for label, value := range map[string]string{ + "product_id": s.ProductID, + "source_tool_id": s.SourceToolID, + "replacement_tool_id": s.ReplacementToolID, + } { + if !isExactSchemaIdentifier(value) { + return fmt.Errorf("schema %s must be an exact identifier", label) + } + } + if s.Parameters == nil { + return fmt.Errorf("schema parameters must be an array") + } + seenFrom := map[string]bool{} + seenTo := map[string]bool{} + seenConstantProperty := map[string]bool{} + for index, parameter := range s.Parameters { + if !isExactFlagName(parameter.From) { + return fmt.Errorf("schema parameter %d from must be an exact parameter name", index) + } + if seenFrom[parameter.From] { + return fmt.Errorf("schema parameter %d duplicates from %q", index, parameter.From) + } + seenFrom[parameter.From] = true + if kind == CommandMigrationMove { + if parameter.ReplacementConstant != nil { + return fmt.Errorf("command_move schema parameter %d must not declare replacement_constant", index) + } + if !isExactFlagName(parameter.To) || parameter.To == parameter.From { + return fmt.Errorf("command_move schema parameter %d requires a distinct exact to name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + if kind != CommandMigrationFlagExtraction { + return fmt.Errorf("invalid command migration kind %q", kind) + } + + hasTo := parameter.To != "" + hasConstant := parameter.ReplacementConstant != nil + if !hasTo && !hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d requires an exact to or replacement_constant", index) + } + if hasTo && hasConstant { + return fmt.Errorf("flag_extraction schema parameter %d must declare exactly one target", index) + } + if hasTo { + if !isExactFlagName(parameter.To) { + return fmt.Errorf("flag_extraction schema parameter %d to must be an exact parameter name", index) + } + if seenTo[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates to %q", index, parameter.To) + } + if seenConstantProperty[parameter.To] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, parameter.To) + } + seenTo[parameter.To] = true + continue + } + property := parameter.ReplacementConstant.Property + if !isExactSchemaIdentifier(property) { + return fmt.Errorf("flag_extraction schema parameter %d replacement_constant requires an exact property", index) + } + if seenConstantProperty[property] { + return fmt.Errorf("schema parameter %d duplicates replacement_constant property %q", index, property) + } + if seenTo[property] { + return fmt.Errorf("schema parameter %d duplicates parameter target %q", index, property) + } + seenConstantProperty[property] = true + } + return nil +} + +func isExactSchemaIdentifier(value string) bool { + return value != "" && value == strings.TrimSpace(value) && + !strings.ContainsAny(value, "*?[]{} /\\\t\r\n") +} + +func (m CommandMigration) key() string { + return strings.Join([]string{m.Kind, m.Legacy.Command, m.Replacement.Command, m.Schema.ProductID, m.Schema.SourceToolID, m.Schema.ReplacementToolID}, "\x00") +} + +func (m CommandMigration) displayKey() string { + return fmt.Sprintf("%s %q -> %q", m.Kind, m.Legacy.Command, m.Replacement.Command) +} + +type commandMigrationPhase string + +const ( + commandMigrationBefore commandMigrationPhase = "before" + commandMigrationAfter commandMigrationPhase = "after" + commandMigrationPartial commandMigrationPhase = "partial" +) + +func AuthorizeCommandMigrations( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if err := current.Validate(); err != nil { + return nil, fmt.Errorf("validate current interface snapshot: %w", err) + } + for label, snapshot := range references { + if err := snapshot.Validate(); err != nil { + return nil, fmt.Errorf("validate %s interface snapshot: %w", label, err) + } + } + if err := authority.Validate(); err != nil { + return nil, fmt.Errorf("validate approved command migrations: %w", err) + } + if err := candidate.Validate(); err != nil { + return nil, fmt.Errorf("validate candidate command migrations: %w", err) + } + type commandMoveTopologyCheck struct { + label string + snapshot Snapshot + manifest CommandMigrationManifest + } + topologyChecks := []commandMoveTopologyCheck{ + {label: "approved", snapshot: current, manifest: authority}, + {label: "candidate", snapshot: current, manifest: candidate}, + } + if mergeBase, _, ok := flagMigrationAuthoritySnapshot(references); ok { + topologyChecks = append(topologyChecks, + commandMoveTopologyCheck{label: "approved base", snapshot: mergeBase, manifest: authority}, + commandMoveTopologyCheck{label: "candidate base", snapshot: mergeBase, manifest: candidate}, + ) + } + for _, check := range topologyChecks { + if err := validateCommandMoveLegacyLeaves(check.snapshot, check.manifest); err != nil { + return nil, fmt.Errorf("validate %s command migration topology: %w", check.label, err) + } + } + return evaluateCommandMigrationLifecycle(current, references, authority, candidate) +} + +func validateCommandMoveLegacyLeaves(snapshot Snapshot, manifest CommandMigrationManifest) error { + commands := commandIndex(snapshot) + for _, migration := range manifest.Migrations { + if migration.Kind != CommandMigrationMove { + continue + } + if _, present := commands[migration.Legacy.Command]; !present { + continue + } + for _, command := range snapshot.Commands { + if strings.HasPrefix(command.Path, migration.Legacy.Command+" ") { + return fmt.Errorf("command_move legacy command %q must be a leaf", migration.Legacy.Command) + } + } + } + return nil +} + +func evaluateCommandMigrationLifecycle( + current Snapshot, + references map[string]Snapshot, + authority CommandMigrationManifest, + candidate CommandMigrationManifest, +) ([]CommandMigration, error) { + if len(authority.Migrations) == 0 && len(candidate.Migrations) == 0 { + return nil, nil + } + if _, ok := references["stable"]; !ok { + return nil, fmt.Errorf("command migration lifecycle requires a stable reference") + } + mergeBase, label, ok := flagMigrationAuthoritySnapshot(references) + if !ok { + return nil, fmt.Errorf("command migration lifecycle requires a main or merge-base reference") + } + authorityByKey := commandMigrationIndex(authority) + candidateByKey := commandMigrationIndex(candidate) + authorizations := make([]CommandMigration, 0, len(authority.Migrations)) + for _, approved := range authority.Migrations { + basePhase := matchCommandMigrationPhase(mergeBase, approved) + wantBase := commandMigrationBefore + if approved.State == CommandMigrationConsumed { + wantBase = commandMigrationAfter + } + if basePhase != wantBase { + return nil, fmt.Errorf("approved command migration %s is %s in %s, want exact %s state for %s", approved.displayKey(), basePhase, label, wantBase, approved.State) + } + proposed, exists := candidateByKey[approved.key()] + if exists && !sameCommandMigrationApproval(approved, proposed) { + return nil, fmt.Errorf("candidate modified base-owned command migration %s", approved.displayKey()) + } + currentPhase := matchCommandMigrationPhase(current, approved) + switch approved.State { + case CommandMigrationPending: + if !exists { + return nil, fmt.Errorf("candidate removed pending command migration %s", approved.displayKey()) + } + switch currentPhase { + case commandMigrationBefore: + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate falsely consumed unchanged command migration %s", approved.displayKey()) + } + case commandMigrationAfter: + if proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate completed command migration %s without marking it consumed", approved.displayKey()) + } + authorizations = append(authorizations, approved) + default: + return nil, fmt.Errorf("candidate partially applied command migration %s", approved.displayKey()) + } + case CommandMigrationConsumed: + if currentPhase != commandMigrationAfter { + return nil, fmt.Errorf("candidate drifted from consumed command migration %s", approved.displayKey()) + } + allAfter := true + for _, reference := range references { + if matchCommandMigrationPhase(reference, approved) != commandMigrationAfter { + allAfter = false + break + } + } + if allAfter { + if exists && proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate changed consumed command migration %s back to pending", approved.displayKey()) + } + continue + } + if !exists || proposed.State != CommandMigrationConsumed { + return nil, fmt.Errorf("candidate must retain consumed command migration %s until every reference reaches the after state", approved.displayKey()) + } + authorizations = append(authorizations, approved) + } + } + for _, proposed := range candidate.Migrations { + if _, exists := authorityByKey[proposed.key()]; exists { + continue + } + if proposed.State != CommandMigrationPending { + return nil, fmt.Errorf("candidate-added command migration %s must start pending", proposed.displayKey()) + } + if matchCommandMigrationPhase(mergeBase, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s does not match the merge-base before state", proposed.displayKey()) + } + if matchCommandMigrationPhase(current, proposed) != commandMigrationBefore { + return nil, fmt.Errorf("candidate-added command migration %s cannot authorize its own interface change", proposed.displayKey()) + } + } + return authorizations, nil +} + +func commandMigrationIndex(manifest CommandMigrationManifest) map[string]CommandMigration { + index := make(map[string]CommandMigration, len(manifest.Migrations)) + for _, migration := range manifest.Migrations { + index[migration.key()] = migration + } + return index +} + +func sameCommandMigrationApproval(left, right CommandMigration) bool { + left.State = "" + right.State = "" + return reflect.DeepEqual(left, right) +} + +func matchCommandMigrationPhase(snapshot Snapshot, migration CommandMigration) commandMigrationPhase { + commands := commandIndex(snapshot) + legacy := commandMigrationStateForCommand(commands, migration.Legacy.Command) + replacement := commandMigrationStateForCommand(commands, migration.Replacement.Command) + before := legacy == migration.Legacy.Before && replacement == migration.Replacement.Before + after := legacy == migration.Legacy.After && replacement == migration.Replacement.After + if migration.Kind == CommandMigrationFlagExtraction { + command, exists := commands[migration.Legacy.Command] + flagState := FlagMigrationState{} + if exists { + flagState = flagMigrationStateForCommand(command, migration.LegacyFlag.Name) + } + before = before && flagState == migration.LegacyFlag.Before + after = after && flagState == migration.LegacyFlag.After + if replacement, replacementExists := commands[migration.Replacement.Command]; replacementExists { + after = after && commandMigrationReplacementConstantsMatch(replacement, migration) + } else { + after = false + } + } + if before { + return commandMigrationBefore + } + if after { + return commandMigrationAfter + } + return commandMigrationPartial +} + +func commandMigrationReplacementConstantsMatch(command Command, migration CommandMigration) bool { + expected := make(map[string]bool) + for _, parameter := range migration.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + expected[parameter.ReplacementConstant.Property] = parameter.ReplacementConstant.Value + } + return reflect.DeepEqual(command.BoolConstParams, expected) +} + +func commandMigrationStateForCommand(commands map[string]Command, path string) CommandMigrationState { + command, exists := commands[path] + if !exists { + return CommandMigrationState{} + } + return CommandMigrationState{Present: true, Runnable: command.Runnable, Hidden: command.Hidden} +} + +// CompareAllWithInterfaceMigrations applies both migration families to one +// ordinary report, so the two ledgers cannot mask each other's unrelated +// findings. +func CompareAllWithInterfaceMigrations( + current Snapshot, + references map[string]Snapshot, + flagAuthority FlagMigrationManifest, + flagCandidate FlagMigrationManifest, + commandAuthority CommandMigrationManifest, + commandCandidate CommandMigrationManifest, +) (Report, error) { + flagAuthorizations, err := AuthorizeFlagMigrations(current, references, flagAuthority, flagCandidate) + if err != nil { + return Report{}, err + } + commandAuthorizations, err := AuthorizeCommandMigrations(current, references, commandAuthority, commandCandidate) + if err != nil { + return Report{}, err + } + report := CompareAll(current, references) + for index := range report.Comparisons { + comparison := &report.Comparisons[index] + reference := references[comparison.Reference] + filtered := comparison.Blocking[:0] + for _, change := range comparison.Blocking { + if flagMigrationAuthorizesChange(current, reference, change, flagAuthorizations) || + commandMigrationAuthorizesChange(current, reference, change, commandAuthorizations) { + continue + } + filtered = append(filtered, change) + } + comparison.Blocking = filtered + comparison.Compatible = len(filtered) == 0 + } + report.Compatible = true + for _, comparison := range report.Comparisons { + if !comparison.Compatible { + report.Compatible = false + break + } + } + return report, nil +} + +func commandMigrationAuthorizesChange(current, reference Snapshot, change Change, authorizations []CommandMigration) bool { + canonicalPath := acceptedPathIndex(reference)[change.Path] + if canonicalPath == "" { + canonicalPath = change.Path + } + for _, migration := range authorizations { + if canonicalPath != migration.Legacy.Command || + matchCommandMigrationPhase(reference, migration) != commandMigrationBefore || + matchCommandMigrationPhase(current, migration) != commandMigrationAfter { + continue + } + switch migration.Kind { + case CommandMigrationMove: + if change.Kind == "command_became_hidden" && change.Flag == "" { + return true + } + case CommandMigrationFlagExtraction: + if change.Kind == "flag_became_hidden" && change.Flag == migration.LegacyFlag.Name { + return true + } + } + } + return false +} diff --git a/internal/interfacesnapshot/command_migrations_test.go b/internal/interfacesnapshot/command_migrations_test.go new file mode 100644 index 00000000..a7d5965e --- /dev/null +++ b/internal/interfacesnapshot/command_migrations_test.go @@ -0,0 +1,747 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package interfacesnapshot + +import ( + "errors" + "os" + "strings" + "testing" +) + +type commandMigrationErrorReader struct{} + +func (commandMigrationErrorReader) Read([]byte) (int, error) { + return 0, errors.New("command migration read failure") +} + +func TestApprovedCommandMigrationManifestRemainsValid(t *testing.T) { + manifest, err := os.Open("../../scripts/policy/interface-migrations/approved-command-migrations-v1.json") + if err != nil { + t.Fatalf("open approved command migration manifest: %v", err) + } + defer manifest.Close() + if _, err := ReadCommandMigrationManifest(manifest); err != nil { + t.Fatalf("approved command migration manifest is invalid: %v", err) + } +} + +func TestCrossPlatformCoverageReadCommandMigrationManifestFailsClosed(t *testing.T) { + valid := commandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(valid)); err != nil { + t.Fatalf("valid command migration manifest: %v", err) + } + validExtraction := flagExtractionCommandMigrationManifestJSON() + if _, err := ReadCommandMigrationManifest(strings.NewReader(validExtraction)); err != nil { + t.Fatalf("valid flag extraction command migration manifest: %v", err) + } + for _, test := range []struct { + input string + want string + }{ + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "allow": true`, 1), "unknown field"}, + {strings.Replace(valid, `"state": "pending"`, `"state": "pending", "state": "consumed"`, 1), "duplicate field"}, + {strings.Replace(valid, `"version": 1`, `"version": null`, 1), "must be"}, + {strings.Replace(valid, "dws chat message old", "dws chat *", 1), "exact command paths"}, + {strings.Replace(valid, `"kind": "command_move"`, `"kind": "anything"`, 1), "invalid kind"}, + {`{"version":1,"migrations":null}`, "must be an array"}, + {strings.Replace(validExtraction, `"replacement_constant":{"property":"convThreadEnabled","value":true}`, `"replacement_constant":null`, 1), "must be an object"}, + {strings.Replace(validExtraction, `,"value":true`, ``, 1), `must be true`}, + {strings.Replace(validExtraction, `"value":true`, `"value":"true"`, 1), "bool"}, + } { + if _, err := ReadCommandMigrationManifest(strings.NewReader(test.input)); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("ReadCommandMigrationManifest() error=%v, want %q", err, test.want) + } + } + if _, err := ReadCommandMigrationManifest(commandMigrationErrorReader{}); err == nil || !strings.Contains(err.Error(), "read failure") { + t.Fatalf("reader error=%v", err) + } + for _, input := range []string{valid + ` {}`, valid + ` {`} { + if _, err := ReadCommandMigrationManifest(strings.NewReader(input)); err == nil || !strings.Contains(err.Error(), "trailing") { + t.Fatalf("trailing input error=%v", err) + } + } +} + +func TestCrossPlatformCoverageCommandMigrationValidationEdges(t *testing.T) { + validMove := commandMigrationManifest(CommandMigrationPending).Migrations[0] + validExtraction := commandMigrationManifest(CommandMigrationPending).Migrations[1] + + duplicate := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{validMove, validMove}} + if err := duplicate.Validate(); err == nil || !strings.Contains(err.Error(), "duplicates") { + t.Fatalf("duplicate error=%v", err) + } + if err := (CommandMigrationManifest{Version: CommandMigrationManifestVersion}).Validate(); err == nil || !strings.Contains(err.Error(), "must be an array") { + t.Fatalf("nil migrations error=%v", err) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"same command", func(m *CommandMigration) { m.Replacement.Command = m.Legacy.Command }, "must differ"}, + {"empty reason", func(m *CommandMigration) { m.Reason = " " }, "non-empty trimmed"}, + {"invalid state", func(m *CommandMigration) { m.State = "approved" }, "invalid state"}, + {"invalid command state", func(m *CommandMigration) { m.Replacement.Before.Runnable = true }, "absent state"}, + {"legacy contract", func(m *CommandMigration) { m.Legacy.Before.Hidden = true }, "remain runnable"}, + {"replacement contract", func(m *CommandMigration) { m.Replacement.After.Hidden = true }, "absent to visible"}, + {"schema contract", func(m *CommandMigration) { m.Schema.ProductID = "bad/id" }, "exact identifier"}, + {"move not hidden", func(m *CommandMigration) { m.Legacy.After.Hidden = false }, "must migrate exactly"}, + {"move flag", func(m *CommandMigration) { m.LegacyFlag.Name = "thread" }, "must not declare legacy_flag"}, + {"move tool identity", func(m *CommandMigration) { m.Schema.ReplacementToolID = "chat.new" }, "stable Schema tool"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := validMove + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + for _, test := range []struct { + name string + mutate func(*CommandMigration) + want string + }{ + {"legacy changed", func(m *CommandMigration) { m.Legacy.After.Hidden = true }, "remain visible"}, + {"invalid legacy flag", func(m *CommandMigration) { m.LegacyFlag.Name = "bad name" }, "exact flag"}, + {"same tool", func(m *CommandMigration) { m.Schema.ReplacementToolID = m.Schema.SourceToolID }, "distinct replacement"}, + {"wrong parameter", func(m *CommandMigration) { m.Schema.Parameters[3].From = "other" }, "exact extracted flag"}, + {"wrong type", func(m *CommandMigration) { + m.LegacyFlag.Before.Type = "string" + m.LegacyFlag.After.Type = "string" + }, "optional bool"}, + {"wrong no opt", func(m *CommandMigration) { + m.LegacyFlag.Before.NoOpt = "false" + m.LegacyFlag.After.NoOpt = "false" + }, "no_opt"}, + {"missing extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = m.Schema.Parameters[:3] + }, "exactly one replacement constant"}, + {"duplicate extracted mapping", func(m *CommandMigration) { + m.Schema.Parameters = append(m.Schema.Parameters, m.Schema.Parameters[3]) + }, "duplicates from"}, + {"multiple constants", func(m *CommandMigration) { + m.Schema.Parameters[0] = CommandParameterMigration{ + From: "name", + ReplacementConstant: &CommandReplacementConstant{ + Property: "name", + Value: true, + }, + } + }, "exactly one replacement constant"}, + } { + t.Run(test.name, func(t *testing.T) { + migration := cloneCommandMigration(validExtraction) + test.mutate(&migration) + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("validate error=%v, want %q", err, test.want) + } + }) + } + falseConstant := cloneCommandMigration(validExtraction) + falseConstant.Schema.Parameters[3].ReplacementConstant.Value = false + falseConstant.LegacyFlag.Before.NoOpt = "false" + falseConstant.LegacyFlag.After.NoOpt = "false" + if err := falseConstant.validate(); err == nil || !strings.Contains(err.Error(), "must be true") { + t.Fatalf("false replacement constant validation error=%v, want v1 true-only rejection", err) + } + + for _, state := range []CommandMigrationState{ + {Runnable: true}, + {Present: true}, + } { + if err := state.validate("state"); err == nil { + t.Fatalf("invalid command state accepted: %#v", state) + } + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + want string + }{ + {"name", func(f *CommandMigrationFlag) { f.Name = "bad name" }, "exact flag"}, + {"before state", func(f *CommandMigrationFlag) { f.Before = FlagMigrationState{Present: true} }, "requires type"}, + {"after state", func(f *CommandMigrationFlag) { f.After = FlagMigrationState{Present: true} }, "requires type"}, + {"visibility", func(f *CommandMigrationFlag) { f.After.Hidden = false }, "visible to hidden"}, + {"attribute", func(f *CommandMigrationFlag) { f.After.Type = "string" }, "only hidden visibility"}, + } { + t.Run("flag "+test.name, func(t *testing.T) { + flag := validExtraction.LegacyFlag + test.mutate(&flag) + if err := flag.validate(); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("flag validate error=%v, want %q", err, test.want) + } + }) + } + + validSchema := validMove.Schema + for _, test := range []struct { + name string + kind string + mutate func(*CommandMigrationSchema) + want string + }{ + {"identifier", CommandMigrationMove, func(s *CommandMigrationSchema) { s.ProductID = "bad/id" }, "exact identifier"}, + {"nil parameters", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = nil }, "must be an array"}, + {"bad from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].From = "bad name" }, "exact parameter"}, + {"duplicate from", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters = append(s.Parameters, s.Parameters[0]) }, "duplicates from"}, + {"bad to", CommandMigrationMove, func(s *CommandMigrationSchema) { s.Parameters[0].To = s.Parameters[0].From }, "distinct exact"}, + {"duplicate to", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters = append(s.Parameters, CommandParameterMigration{From: "other", To: s.Parameters[0].To}) + }, "duplicates to"}, + {"move constant", CommandMigrationMove, func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "property", Value: true} + }, "must not declare replacement_constant"}, + {"invalid kind", "anything", func(*CommandMigrationSchema) {}, "invalid command migration kind"}, + } { + t.Run("schema "+test.name, func(t *testing.T) { + schema := validSchema + schema.Parameters = append([]CommandParameterMigration(nil), validSchema.Parameters...) + test.mutate(&schema) + if err := schema.validate(test.kind); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } + + for _, test := range []struct { + name string + mutate func(*CommandMigrationSchema) + want string + }{ + {"missing target", func(s *CommandMigrationSchema) { s.Parameters[0].To = "" }, "requires an exact to or replacement_constant"}, + {"both targets", func(s *CommandMigrationSchema) { + s.Parameters[0].ReplacementConstant = &CommandReplacementConstant{Property: "name", Value: true} + }, "exactly one target"}, + {"bad to", func(s *CommandMigrationSchema) { s.Parameters[0].To = "bad name" }, "to must be an exact parameter name"}, + {"bad constant property", func(s *CommandMigrationSchema) { s.Parameters[3].ReplacementConstant.Property = "bad/property" }, "exact property"}, + {"duplicate parameter target", func(s *CommandMigrationSchema) { s.Parameters[1].To = s.Parameters[0].To }, "duplicates to"}, + {"duplicate constant target", func(s *CommandMigrationSchema) { + s.Parameters[0] = CommandParameterMigration{From: "name", ReplacementConstant: &CommandReplacementConstant{Property: "convThreadEnabled", Value: true}} + }, "duplicates replacement_constant property"}, + {"constant before parameter target", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + s.Parameters[0], s.Parameters[3] = s.Parameters[3], s.Parameters[0] + }, "duplicates parameter target"}, + {"parameter target before constant", func(s *CommandMigrationSchema) { + s.Parameters[3].ReplacementConstant.Property = "name" + }, "duplicates parameter target"}, + } { + t.Run("extraction schema "+test.name, func(t *testing.T) { + schema := cloneCommandMigration(validExtraction).Schema + test.mutate(&schema) + if err := schema.validate(CommandMigrationFlagExtraction); err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("schema validate error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageFlagExtractionRejectsRequiredLegacyFlag(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*CommandMigrationFlag) + }{ + {"before required", func(flag *CommandMigrationFlag) { flag.Before.Required = true }}, + {"after required", func(flag *CommandMigrationFlag) { flag.After.Required = true }}, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[1] + test.mutate(&migration.LegacyFlag) + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "optional") { + t.Fatalf("required legacy flag validation error=%v, want optional-only rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsRunnableParent(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = "dws agoal" + migration.Replacement.Command = "dws goal" + migration.Schema = CommandMigrationSchema{ + ProductID: "agoal", + SourceToolID: "agoal.root", + ReplacementToolID: "agoal.root", + Parameters: []CommandParameterMigration{}, + } + authority := CommandMigrationManifest{ + Version: CommandMigrationManifestVersion, + Migrations: []CommandMigration{migration}, + } + consumed := authority + consumed.Migrations = append([]CommandMigration(nil), authority.Migrations...) + consumed.Migrations[0].State = CommandMigrationConsumed + + before := testSnapshot( + testCommand("dws"), + testCommand("dws agoal"), + testCommand("dws agoal strategy"), + testCommand("dws agoal strategy list"), + ) + after := testSnapshot( + testCommand("dws"), + Command{Path: "dws agoal", Runnable: true, Hidden: true}, + testCommand("dws goal"), + ) + + _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + authority, + consumed, + ) + if err == nil || !strings.Contains(err.Error(), "must be a leaf") { + t.Fatalf("runnable parent command_move error=%v, want leaf-only rejection", err) + } +} + +func TestCrossPlatformCoverageCommandMoveAllowsReplacementParent(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + after.Commands = append(after.Commands, testCommand("dws chat topic new detail")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + if err := validateCommandMoveLegacyLeaves(testSnapshot(testCommand("dws")), pending); err != nil { + t.Fatalf("absent legacy topology should remain a lifecycle concern: %v", err) + } + + got, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil { + t.Fatalf("replacement parent command_move was rejected: %v", err) + } + if len(got) != 1 { + t.Fatalf("replacement parent command_move authorizations=%d, want 1", len(got)) + } +} + +func TestCrossPlatformCoverageCommandMoveRejectsAncestorOverlap(t *testing.T) { + for _, test := range []struct { + name string + legacy string + replacement string + }{ + { + name: "legacy ancestor", + legacy: "dws chat message", + replacement: "dws chat message list", + }, + { + name: "replacement ancestor", + legacy: "dws chat message list", + replacement: "dws chat message", + }, + } { + t.Run(test.name, func(t *testing.T) { + migration := commandMigrationManifest(CommandMigrationPending).Migrations[0] + migration.Legacy.Command = test.legacy + migration.Replacement.Command = test.replacement + + if err := migration.validate(); err == nil || !strings.Contains(err.Error(), "must not have an ancestor relationship") { + t.Fatalf("overlapping command_move error=%v, want ancestor-overlap rejection", err) + } + }) + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleEdges(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + partial := commandMigrationSnapshot(false, false) + partial.Commands = append(partial.Commands, testCommand("dws chat topic new")) + pending := singleCommandMigrationManifest(CommandMigrationPending) + consumed := singleCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + + if got, err := AuthorizeCommandMigrations(before, map[string]Snapshot{}, empty, empty); err != nil || len(got) != 0 { + t.Fatalf("empty lifecycle=%#v, %v", got, err) + } + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + want string + }{ + {"missing stable", before, map[string]Snapshot{"main": before}, pending, pending, "stable reference"}, + {"missing base", before, map[string]Snapshot{"stable": before}, pending, pending, "main or merge-base"}, + {"pending base after", after, map[string]Snapshot{"main": after, "stable": before}, pending, pending, "want exact before"}, + {"modified approval", before, map[string]Snapshot{"main": before, "stable": before}, pending, modifiedCommandManifest(pending), "modified base-owned"}, + {"pending removed", before, map[string]Snapshot{"main": before, "stable": before}, pending, empty, "removed pending"}, + {"false consumed", before, map[string]Snapshot{"main": before, "stable": before}, pending, consumed, "falsely consumed"}, + {"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"}, + {"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"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("lifecycle error=%v, want %q", err, test.want) + } + }) + } + if got, err := AuthorizeCommandMigrations(after, map[string]Snapshot{"main": after, "stable": before}, consumed, consumed); err != nil || len(got) != 1 { + t.Fatalf("retained consumed receipt=%#v, %v", got, err) + } + 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) + } + + invalidSnapshot := before + invalidSnapshot.SchemaVersion = 0 + for _, test := range []struct { + name string + current Snapshot + references map[string]Snapshot + authority CommandMigrationManifest + candidate CommandMigrationManifest + }{ + {"current", invalidSnapshot, map[string]Snapshot{"main": before}, empty, empty}, + {"reference", before, map[string]Snapshot{"main": invalidSnapshot}, empty, empty}, + {"authority", before, map[string]Snapshot{"main": before}, CommandMigrationManifest{}, empty}, + {"candidate", before, map[string]Snapshot{"main": before}, empty, CommandMigrationManifest{}}, + } { + t.Run("invalid "+test.name, func(t *testing.T) { + if _, err := AuthorizeCommandMigrations(test.current, test.references, test.authority, test.candidate); err == nil { + t.Fatal("invalid authorization input accepted") + } + }) + } + invalidFlags := FlagMigrationManifest{} + validFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + validCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, invalidFlags, validFlags, validCommands, validCommands); err == nil { + t.Fatal("combined compare accepted invalid flag lifecycle") + } + if _, err := CompareAllWithInterfaceMigrations(before, map[string]Snapshot{"main": before}, validFlags, validFlags, CommandMigrationManifest{}, validCommands); err == nil { + t.Fatal("combined compare accepted invalid command lifecycle") + } + if commandMigrationAuthorizesChange(before, before, Change{Kind: "command_removed", Path: "dws unrelated"}, pending.Migrations) { + t.Fatal("unrelated command change was authorized") + } +} + +func TestCrossPlatformCoverageCommandMigrationLifecycleAndExactFiltering(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := commandMigrationManifest(CommandMigrationPending) + consumed := commandMigrationManifest(CommandMigrationConsumed) + emptyFlags := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + + report, err := CompareAllWithInterfaceMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatalf("governed command migration: %v", err) + } + if !report.Compatible { + t.Fatalf("exact command migration remained blocking: %#v", report.Comparisons) + } + + unrelated := commandMigrationSnapshot(true, true) + report, err = CompareAllWithInterfaceMigrations( + unrelated, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyFlags, + emptyFlags, + pending, + consumed, + ) + if err != nil { + t.Fatal(err) + } + if report.Compatible || !hasChangeKind(report.Comparisons[0].Blocking, "flag_removed") { + t.Fatalf("unrelated flag removal was hidden: %#v", report.Comparisons) + } + + emptyCommands := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + if _, err := AuthorizeCommandMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + emptyCommands, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error=%v", err) + } + + partial := commandMigrationSnapshot(true, false) + partial.Commands = partial.Commands[:len(partial.Commands)-1] + if _, err := AuthorizeCommandMigrations( + partial, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("partial command migration error=%v", err) + } +} + +func TestCrossPlatformCoverageFlagExtractionRequiresReplacementConstantEvidence(t *testing.T) { + before := commandMigrationSnapshot(false, false) + after := commandMigrationSnapshot(true, false) + pending := flagExtractionCommandMigrationManifest(CommandMigrationPending) + consumed := flagExtractionCommandMigrationManifest(CommandMigrationConsumed) + empty := CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{}} + migration := pending.Migrations[0] + + if phase := matchCommandMigrationPhase(before, migration); phase != commandMigrationBefore { + t.Fatalf("before phase=%s, want %s", phase, commandMigrationBefore) + } + if phase := matchCommandMigrationPhase(after, migration); phase != commandMigrationAfter { + t.Fatalf("after phase=%s, want %s", phase, commandMigrationAfter) + } + + for _, test := range []struct { + name string + values map[string]bool + }{ + {name: "missing", values: nil}, + {name: "wrong property", values: map[string]bool{"otherProperty": true}}, + {name: "wrong value", values: map[string]bool{"convThreadEnabled": false}}, + {name: "extra", values: map[string]bool{"convThreadEnabled": true, "otherProperty": true}}, + } { + t.Run(test.name, func(t *testing.T) { + current := withCommandBoolConstParams(after, "dws chat topic create", test.values) + if phase := matchCommandMigrationPhase(current, migration); phase != commandMigrationPartial { + t.Fatalf("phase=%s, want %s", phase, commandMigrationPartial) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ); err == nil || !strings.Contains(err.Error(), "partially applied") { + t.Fatalf("constant evidence lifecycle error=%v, want partial rejection", err) + } + if _, err := AuthorizeCommandMigrations( + current, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("constant evidence self-authorization error=%v", err) + } + }) + } +} + +func commandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "command_move", + "legacy": { + "command": "dws chat message old", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic new", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.old", + "replacement_tool_id": "chat.old", + "parameters": [{"from": "old-id", "to": "new-id"}] + }, + "state": "pending", + "reason": "Reviewed command move." + }] +}` +} + +func flagExtractionCommandMigrationManifestJSON() string { + return `{ + "version": 1, + "migrations": [{ + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group", + "replacement_tool_id": "chat.create_topic", + "parameters": [ + {"from":"name","to":"name"}, + {"from":"type","to":"type"}, + {"from":"users","to":"users"}, + {"from":"thread","replacement_constant":{"property":"convThreadEnabled","value":true}} + ] + }, + "state": "pending", + "reason": "Reviewed flag extraction." + }] +}` +} + +func commandMigrationManifest(state string) CommandMigrationManifest { + move := CommandMigration{ + Kind: CommandMigrationMove, + Legacy: CommandMigrationSide{ + Command: "dws chat message old", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic new", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.old", + ReplacementToolID: "chat.old", + Parameters: []CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + State: state, + Reason: "Reviewed command move.", + } + extraction := CommandMigration{ + Kind: CommandMigrationFlagExtraction, + Legacy: CommandMigrationSide{ + Command: "dws chat group create", + Before: CommandMigrationState{Present: true, Runnable: true}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: CommandMigrationSide{ + Command: "dws chat topic create", + Before: CommandMigrationState{}, + After: CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: CommandMigrationFlag{ + Name: "thread", + Before: FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &CommandReplacementConstant{ + Property: "convThreadEnabled", + Value: true, + }, + }, + }, + }, + State: state, + Reason: "Reviewed flag extraction.", + } + return CommandMigrationManifest{Version: CommandMigrationManifestVersion, Migrations: []CommandMigration{move, extraction}} +} + +func flagExtractionCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[1:] + return manifest +} + +func cloneCommandMigration(source CommandMigration) CommandMigration { + cloned := source + cloned.Schema.Parameters = append([]CommandParameterMigration(nil), source.Schema.Parameters...) + for index, parameter := range source.Schema.Parameters { + if parameter.ReplacementConstant == nil { + continue + } + constant := *parameter.ReplacementConstant + cloned.Schema.Parameters[index].ReplacementConstant = &constant + } + return cloned +} + +func singleCommandMigrationManifest(state string) CommandMigrationManifest { + manifest := commandMigrationManifest(state) + manifest.Migrations = manifest.Migrations[:1] + return manifest +} + +func modifiedCommandManifest(source CommandMigrationManifest) CommandMigrationManifest { + modified := source + modified.Migrations = append([]CommandMigration(nil), source.Migrations...) + modified.Migrations[0].Reason = "Modified reason." + return modified +} + +func commandMigrationSnapshot(after, removeUnrelated bool) Snapshot { + oldFlags := []Flag{{Name: "old-id", Type: "string", Required: true}, {Name: "keep", Type: "string"}} + groupFlags := []Flag{{Name: "thread", Type: "bool", NoOpt: "true"}} + commands := []Command{ + testCommand("dws"), + testCommand("dws chat group create", groupFlags...), + testCommand("dws chat message old", oldFlags...), + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + if removeUnrelated { + commands[2].LocalFlags = commands[2].LocalFlags[:1] + } + topicCreate := testCommand("dws chat topic create") + topicCreate.BoolConstParams = map[string]bool{"convThreadEnabled": true} + commands = append(commands, + topicCreate, + testCommand("dws chat topic new", Flag{Name: "new-id", Type: "string", Required: true}), + ) + } + return testSnapshot(commands...) +} + +func withCommandBoolConstParams(snapshot Snapshot, path string, values map[string]bool) Snapshot { + cloned := snapshot + cloned.Commands = append([]Command(nil), snapshot.Commands...) + for index := range cloned.Commands { + if cloned.Commands[index].Path == path { + cloned.Commands[index].BoolConstParams = values + break + } + } + return cloned +} diff --git a/internal/interfacesnapshot/compare.go b/internal/interfacesnapshot/compare.go index e520626d..5de0dd9e 100644 --- a/internal/interfacesnapshot/compare.go +++ b/internal/interfacesnapshot/compare.go @@ -71,6 +71,9 @@ func CompareAll(current Snapshot, references map[string]Snapshot) Report { // alias), // - flags accepted at each command path may not disappear, change type, or // become required; an existing path may not gain a new required flag, +// - once bool ConstParams evidence exists, its exact property/value map is a +// durable contract; adding the first evidence to an older snapshot remains +// a silent bootstrap, // - new commands and flags are allowed. // // The single exception to the type rule is an individually reviewed migration @@ -153,6 +156,14 @@ func Compare(current, baseline Snapshot, reference string) Comparison { } func compareCommandContract(result *Comparison, acceptedPath string, oldCommand, newCommand Command) { + if len(oldCommand.BoolConstParams) > 0 && !reflect.DeepEqual(oldCommand.BoolConstParams, newCommand.BoolConstParams) { + result.Blocking = append(result.Blocking, Change{ + Kind: "bool_const_params_changed", + Path: acceptedPath, + Before: fmt.Sprintf("%v", oldCommand.BoolConstParams), + After: fmt.Sprintf("%v", newCommand.BoolConstParams), + }) + } if oldCommand.Runnable && !newCommand.Runnable { result.Blocking = append(result.Blocking, Change{ Kind: "command_became_non_runnable", diff --git a/internal/interfacesnapshot/compare_test.go b/internal/interfacesnapshot/compare_test.go index 9dab9936..ee362dee 100644 --- a/internal/interfacesnapshot/compare_test.go +++ b/internal/interfacesnapshot/compare_test.go @@ -135,6 +135,63 @@ func TestCrossPlatformCoverageCompareAllowsRenameWhenOldPathIsAlias(t *testing.T } } +func TestCrossPlatformCoverageCompareAllowsBoolConstParamsBootstrapAddition(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + comparison := Compare(current, base, "base") + if !comparison.Compatible || len(comparison.Blocking) != 0 || len(comparison.Additions) != 0 { + t.Fatalf("ordinary Compare treated bootstrapped bool ConstParams evidence as a compatibility change: %#v", comparison) + } +} + +func TestCrossPlatformCoverageCompareBlocksBoolConstParamsDriftAfterBootstrap(t *testing.T) { + base := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + base.Commands[1].BoolConstParams = map[string]bool{"convThreadEnabled": true} + + for _, test := range []struct { + name string + current map[string]bool + }{ + {name: "removed", current: nil}, + {name: "value flipped", current: map[string]bool{"convThreadEnabled": false}}, + {name: "extra key", current: map[string]bool{"convThreadEnabled": true, "other": false}}, + } { + t.Run(test.name, func(t *testing.T) { + current := testSnapshot( + testCommand("dws"), + testCommand("dws send"), + ) + current.Commands[1].BoolConstParams = test.current + + comparison := Compare(current, base, "base") + if comparison.Compatible { + t.Fatalf("historical bool ConstParams drift was accepted: %#v", comparison) + } + if len(comparison.Blocking) != 1 { + t.Fatalf("blocking=%#v, want exactly one durable contract change", comparison.Blocking) + } + change := comparison.Blocking[0] + if change.Kind != "bool_const_params_changed" || change.Path != "dws send" { + t.Fatalf("blocking=%#v, want bool_const_params_changed at dws send", comparison.Blocking) + } + if len(comparison.Additions) != 0 { + t.Fatalf("bool ConstParams drift also produced additions: %#v", comparison.Additions) + } + }) + } +} + func TestCrossPlatformCoverageCompareBlocksRemovedAlias(t *testing.T) { base := testSnapshot( testCommand("dws"), @@ -832,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{ @@ -840,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) } diff --git a/internal/interfacesnapshot/migrations.go b/internal/interfacesnapshot/migrations.go index 892a7c55..6d50aa2c 100644 --- a/internal/interfacesnapshot/migrations.go +++ b/internal/interfacesnapshot/migrations.go @@ -17,6 +17,9 @@ const FlagMigrationManifestVersion = 1 const ( FlagMigrationPending = "pending" FlagMigrationConsumed = "consumed" + + FlagMigrationRename = "flag_rename" + FlagMigrationRequirednessChange = "requiredness_change" ) type FlagMigrationManifest struct { @@ -25,11 +28,13 @@ type FlagMigrationManifest struct { } type FlagMigration struct { - Command string `json:"command"` - Legacy FlagMigrationSide `json:"legacy"` - Canonical FlagMigrationSide `json:"canonical"` - State string `json:"state"` - Reason string `json:"reason"` + Kind string `json:"kind,omitempty"` + Command string `json:"command"` + Legacy FlagMigrationSide `json:"legacy"` + Canonical FlagMigrationSide `json:"canonical"` + Flag *FlagMigrationSide `json:"flag,omitempty"` + State string `json:"state"` + Reason string `json:"reason"` } type FlagMigrationSide struct { @@ -91,22 +96,32 @@ func validateFlagMigrationJSONSchema(data []byte) error { } func validateMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type) error { + return validateLabeledMigrationJSONValue(decoder, path, schema, "flag") +} + +func validateLabeledMigrationJSONValue(decoder *json.Decoder, path string, schema reflect.Type, label string) error { token, err := decoder.Token() if err != nil { - return fmt.Errorf("read flag migration manifest value at %s: %w", path, err) + return fmt.Errorf("read %s migration manifest value at %s: %w", label, path, err) + } + for schema.Kind() == reflect.Pointer { + if token == nil { + return migrationJSONTypeError(path, schema.Elem(), token, label) + } + schema = schema.Elem() } switch schema.Kind() { case reflect.Struct: if delimiter, ok := token.(json.Delim); !ok || delimiter != '{' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } fields := migrationJSONFields(schema) seen := make(map[string]bool, len(fields)) for decoder.More() { keyToken, keyErr := decoder.Token() if keyErr != nil { - return fmt.Errorf("read flag migration manifest field at %s: %w", path, keyErr) + return fmt.Errorf("read %s migration manifest field at %s: %w", label, path, keyErr) } // encoding/json guarantees object member names are string tokens. key := keyToken.(string) @@ -115,57 +130,58 @@ func validateMigrationJSONValue(decoder *json.Decoder, path string, schema refle for canonical := range fields { if strings.EqualFold(key, canonical) { return fmt.Errorf( - "flag migration manifest contains non-canonical field %q at %s (want %q)", + "%s migration manifest contains non-canonical field %q at %s (want %q)", + label, key, path, canonical, ) } } - return fmt.Errorf("flag migration manifest contains unknown field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains unknown field %q at %s", label, key, path) } if seen[key] { - return fmt.Errorf("flag migration manifest contains duplicate field %q at %s", key, path) + return fmt.Errorf("%s migration manifest contains duplicate field %q at %s", label, key, path) } seen[key] = true - if err := validateMigrationJSONValue(decoder, path+"."+key, fieldSchema); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, path+"."+key, fieldSchema, label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest object at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest object at %s: %w", label, path, closeErr) } return nil case reflect.Slice: if delimiter, ok := token.(json.Delim); !ok || delimiter != '[' { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } for index := 0; decoder.More(); index++ { - if err := validateMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem()); err != nil { + if err := validateLabeledMigrationJSONValue(decoder, fmt.Sprintf("%s[%d]", path, index), schema.Elem(), label); err != nil { return err } } if _, closeErr := decoder.Token(); closeErr != nil { - return fmt.Errorf("close flag migration manifest array at %s: %w", path, closeErr) + return fmt.Errorf("close %s migration manifest array at %s: %w", label, path, closeErr) } return nil case reflect.String: if _, ok := token.(string); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Int: if _, ok := token.(json.Number); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil case reflect.Bool: if _, ok := token.(bool); !ok { - return migrationJSONTypeError(path, schema, token) + return migrationJSONTypeError(path, schema, token, label) } return nil default: - return fmt.Errorf("flag migration manifest value at %s has unsupported Go schema type %s", path, schema) + return fmt.Errorf("%s migration manifest value at %s has unsupported Go schema type %s", label, path, schema) } } @@ -188,9 +204,10 @@ func migrationJSONFields(schema reflect.Type) map[string]reflect.Type { return fields } -func migrationJSONTypeError(path string, want reflect.Type, token json.Token) error { +func migrationJSONTypeError(path string, want reflect.Type, token json.Token, label string) error { return fmt.Errorf( - "flag migration manifest value at %s must be %s, got %s", + "%s migration manifest value at %s must be %s, got %s", + label, path, migrationJSONKindDescription(want), migrationJSONTokenDescription(token), @@ -275,6 +292,23 @@ func (m FlagMigrationManifest) Validate() error { } legacyTargets[legacyKey] = migration.Canonical.Name } + for _, requiredness := range m.Migrations { + if requiredness.EffectiveKind() != FlagMigrationRequirednessChange { + continue + } + for _, rename := range m.Migrations { + if rename.EffectiveKind() != FlagMigrationRename || rename.Command != requiredness.Command { + continue + } + if rename.Legacy.Name == requiredness.Flag.Name || rename.Canonical.Name == requiredness.Flag.Name { + return fmt.Errorf( + "flag requiredness migration %s overlaps rename migration %s", + requiredness.displayKey(), + rename.displayKey(), + ) + } + } + } return nil } @@ -282,15 +316,6 @@ func (m FlagMigration) validate() error { if !isExactCommandPath(m.Command) { return fmt.Errorf("command must be an exact command path rooted at dws: %q", m.Command) } - if !isExactFlagName(m.Legacy.Name) { - return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name) - } - if !isExactFlagName(m.Canonical.Name) { - return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name) - } - if m.Legacy.Name == m.Canonical.Name { - return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name) - } if strings.TrimSpace(m.Reason) == "" { return fmt.Errorf("migration must include a non-empty reason") } @@ -300,6 +325,30 @@ func (m FlagMigration) validate() error { if m.State != FlagMigrationPending && m.State != FlagMigrationConsumed { return fmt.Errorf("invalid state %q", m.State) } + + switch m.EffectiveKind() { + case FlagMigrationRename: + return m.validateRename() + case FlagMigrationRequirednessChange: + return m.validateRequirednessChange() + default: + return fmt.Errorf("invalid kind %q", m.Kind) + } +} + +func (m FlagMigration) validateRename() error { + if m.Flag != nil { + return fmt.Errorf("flag rename must not declare flag requiredness fields") + } + if !isExactFlagName(m.Legacy.Name) { + return fmt.Errorf("legacy name must be an exact legacy flag: %q", m.Legacy.Name) + } + if !isExactFlagName(m.Canonical.Name) { + return fmt.Errorf("canonical name must be an exact canonical flag: %q", m.Canonical.Name) + } + if m.Legacy.Name == m.Canonical.Name { + return fmt.Errorf("legacy and canonical flags must differ: --%s", m.Legacy.Name) + } if err := m.Legacy.Before.validate("legacy before"); err != nil { return err } @@ -380,6 +429,45 @@ func (m FlagMigration) validate() error { return nil } +func (m FlagMigration) validateRequirednessChange() error { + if m.Legacy != (FlagMigrationSide{}) || m.Canonical != (FlagMigrationSide{}) { + return fmt.Errorf("flag requiredness migration must not declare legacy or canonical rename fields") + } + if m.Flag == nil || !isExactFlagName(m.Flag.Name) { + return fmt.Errorf("flag name must be an exact flag: %q", flagMigrationSideName(m.Flag)) + } + if err := m.Flag.Before.validate("flag before"); err != nil { + return err + } + if err := m.Flag.After.validate("flag after"); err != nil { + return err + } + if !m.Flag.Before.Present || !m.Flag.After.Present || m.Flag.Before.Required || !m.Flag.After.Required { + return fmt.Errorf("flag requiredness migration must change exactly from optional to required") + } + if m.Flag.Before.Hidden || m.Flag.After.Hidden { + return fmt.Errorf("flag requiredness migration flag must stay visible") + } + if m.Flag.Before.AliasOf != "" || m.Flag.After.AliasOf != "" { + return fmt.Errorf("flag requiredness migration must not declare alias_of") + } + before := m.Flag.Before + after := m.Flag.After + before.Required = false + after.Required = false + if before != after { + return fmt.Errorf("flag requiredness migration must preserve every flag attribute except requiredness") + } + return nil +} + +func flagMigrationSideName(side *FlagMigrationSide) string { + if side == nil { + return "" + } + return side.Name +} + func (s FlagMigrationState) validate(label string) error { if !s.Present { if s.Type != "" || s.Required || s.Hidden || s.Shorthand != "" || s.NoOpt != "" || s.Scope != "" || s.AliasOf != "" { @@ -397,7 +485,19 @@ func (s FlagMigrationState) validate(label string) error { } func (m FlagMigration) key() string { - return m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name + if m.EffectiveKind() == FlagMigrationRequirednessChange { + return m.EffectiveKind() + "\x00" + m.Command + "\x00" + flagMigrationSideName(m.Flag) + } + return m.EffectiveKind() + "\x00" + m.Command + "\x00" + m.Legacy.Name + "\x00" + m.Canonical.Name +} + +// EffectiveKind keeps manifests written before kinds were introduced valid. +// An omitted kind is the original flag rename primitive. +func (m FlagMigration) EffectiveKind() string { + if m.Kind == "" { + return FlagMigrationRename + } + return m.Kind } func isExactCommandPath(path string) bool { @@ -416,9 +516,9 @@ func isExactFlagName(name string) bool { } // CompareAllWithFlagMigrations applies the ordinary compatibility policy and -// then consumes only exact, merge-base-owned flag migrations. Candidate-owned -// records participate in the lifecycle check, but never authorize their own -// interface change. +// then consumes only exact, merge-base-owned flag rename or requiredness +// migrations. Candidate-owned records participate in the lifecycle check, but +// never authorize their own interface change. func CompareAllWithFlagMigrations( current Snapshot, references map[string]Snapshot, @@ -567,8 +667,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 } @@ -628,6 +728,16 @@ func matchFlagMigrationPhase(snapshot Snapshot, migration FlagMigration) flagMig if !exists { return flagMigrationPartial } + if migration.EffectiveKind() == FlagMigrationRequirednessChange { + state := flagMigrationStateForCommand(command, flagMigrationSideName(migration.Flag)) + if state == migration.Flag.Before { + return flagMigrationBefore + } + if state == migration.Flag.After { + return flagMigrationAfter + } + return flagMigrationPartial + } legacy := flagMigrationStateForCommand(command, migration.Legacy.Name) canonical := flagMigrationStateForCommand(command, migration.Canonical.Name) if legacy == migration.Legacy.Before && canonical == migration.Canonical.Before { @@ -682,6 +792,12 @@ func flagMigrationAuthorizesChange( matchFlagMigrationPhase(current, migration) != flagMigrationAfter { continue } + if migration.EffectiveKind() == FlagMigrationRequirednessChange { + if change.Flag == migration.Flag.Name && change.Kind == "flag_became_required" { + return true + } + continue + } if change.Flag == migration.Legacy.Name && change.Kind == "flag_became_hidden" { return true } @@ -705,5 +821,8 @@ func flagMigrationAuthorizesChange( } func (m FlagMigration) displayKey() string { + if m.EffectiveKind() == FlagMigrationRequirednessChange { + return fmt.Sprintf("%q --%s optional -> required", m.Command, flagMigrationSideName(m.Flag)) + } return fmt.Sprintf("%q --%s -> --%s", m.Command, m.Legacy.Name, m.Canonical.Name) } diff --git a/internal/interfacesnapshot/migrations_coverage_test.go b/internal/interfacesnapshot/migrations_coverage_test.go index fa4b239f..90f8064a 100644 --- a/internal/interfacesnapshot/migrations_coverage_test.go +++ b/internal/interfacesnapshot/migrations_coverage_test.go @@ -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) } }) } diff --git a/internal/interfacesnapshot/migrations_test.go b/internal/interfacesnapshot/migrations_test.go index f9c406f7..4b3e9e75 100644 --- a/internal/interfacesnapshot/migrations_test.go +++ b/internal/interfacesnapshot/migrations_test.go @@ -38,6 +38,23 @@ const validFlagMigrationManifestJSON = `{ ] }` +const validRequirednessMigrationManifestJSON = `{ + "version": 1, + "migrations": [ + { + "kind": "requiredness_change", + "command": "dws report entry submit", + "flag": { + "name": "to-user-ids", + "before": {"present": true, "type": "string", "scope": "local"}, + "after": {"present": true, "type": "string", "required": true, "scope": "local"} + }, + "state": "pending", + "reason": "Reject report submissions that have no visible recipient." + } + ] +}` + func optionalFlagMigrationManifestJSON() string { manifest := strings.Replace( validFlagMigrationManifestJSON, @@ -153,6 +170,9 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * if _, err := ReadFlagMigrationManifest(strings.NewReader(hiddenCanonicalFlagMigrationManifestJSON())); err != nil { t.Fatalf("ReadFlagMigrationManifest(hidden canonical promotion) error = %v", err) } + if _, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)); err != nil { + t.Fatalf("ReadFlagMigrationManifest(requiredness change) error = %v", err) + } tests := []struct { name string @@ -246,6 +266,229 @@ func TestCrossPlatformCoverageReadFlagMigrationManifestValidatesExactEntries(t * } } +func TestCrossPlatformCoverageRequirednessMigrationRejectsInexactContracts(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + { + name: "unknown kind", + input: strings.Replace(validRequirednessMigrationManifestJSON, `"requiredness_change"`, `"anything"`, 1), + wantErr: "invalid kind", + }, + { + name: "missing flag name", + input: strings.Replace(validRequirednessMigrationManifestJSON, `"to-user-ids"`, `""`, 1), + wantErr: "exact flag", + }, + { + name: "invalid before state", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"before": {"present": true, "type": "string", "scope": "local"}`, + `"before": {"present": true, "type": "string", "scope": "anything"}`, + 1, + ), + wantErr: "flag before present state has invalid scope", + }, + { + name: "invalid after state", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "scope": "anything"}`, + 1, + ), + wantErr: "flag after present state has invalid scope", + }, + { + name: "before already required", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"before": {"present": true, "type": "string", "scope": "local"}`, + `"before": {"present": true, "type": "string", "required": true, "scope": "local"}`, + 1, + ), + wantErr: "optional to required", + }, + { + name: "after remains optional", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "scope": "local"}`, + 1, + ), + wantErr: "optional to required", + }, + { + name: "type drift", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "stringSlice", "required": true, "scope": "local"}`, + 1, + ), + wantErr: "must preserve every flag attribute except requiredness", + }, + { + name: "visibility drift", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "hidden": true, "scope": "local"}`, + 1, + ), + wantErr: "must stay visible", + }, + { + name: "alias relation", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"after": {"present": true, "type": "string", "required": true, "scope": "local"}`, + `"after": {"present": true, "type": "string", "required": true, "scope": "local", "alias_of": "other"}`, + 1, + ), + wantErr: "must not declare alias_of", + }, + { + name: "rename fields cannot be combined", + input: strings.Replace( + validRequirednessMigrationManifestJSON, + `"flag": {`, + `"legacy": {"name":"old","before":{"present":false},"after":{"present":false}}, "flag": {`, + 1, + ), + wantErr: "must not declare legacy or canonical", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := ReadFlagMigrationManifest(strings.NewReader(test.input)) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("ReadFlagMigrationManifest() error = %v, want %q", err, test.wantErr) + } + }) + } + + missingFlag := FlagMigration{ + Kind: FlagMigrationRequirednessChange, + Command: "dws report entry submit", + State: FlagMigrationPending, + Reason: "Missing exact flag state.", + } + if err := missingFlag.validate(); err == nil || !strings.Contains(err.Error(), "exact flag") { + t.Fatalf("missing requiredness flag error = %v", err) + } + + rename, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + rename.Migrations[0].Kind = FlagMigrationRename + rename.Migrations[0].Flag = &FlagMigrationSide{ + Name: "unrelated", + Before: FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + } + if err := rename.Validate(); err == nil || !strings.Contains(err.Error(), "must not declare flag requiredness fields") { + t.Fatalf("rename with requiredness fields error = %v", err) + } +} + +func TestCrossPlatformCoverageRequirednessMigrationLifecycleAndAuthorization(t *testing.T) { + pending, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + consumed := pending + consumed.Migrations = append([]FlagMigration(nil), pending.Migrations...) + consumed.Migrations[0].State = FlagMigrationConsumed + migration := pending.Migrations[0] + before := requirednessMigrationSnapshot(migration, false) + after := requirednessMigrationSnapshot(migration, true) + + ordinary := Compare(after, before, "merge-base") + if !hasFlagChange(ordinary.Blocking, "flag_became_required", migration.Command, migration.Flag.Name) { + t.Fatalf("fixture did not produce flag_became_required: %#v", ordinary.Blocking) + } + report, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + pending, + consumed, + ) + if err != nil || !report.Compatible { + t.Fatalf("governed requiredness change = (%#v, %v), want compatible", report, err) + } + partial := after + partial.Commands = append([]Command(nil), after.Commands...) + partial.Commands[len(partial.Commands)-1].LocalFlags = append( + []Flag(nil), + after.Commands[len(after.Commands)-1].LocalFlags..., + ) + partial.Commands[len(partial.Commands)-1].LocalFlags[0].Type = "stringSlice" + if phase := matchFlagMigrationPhase(partial, migration); phase != flagMigrationPartial { + t.Fatalf("drifted requiredness migration phase = %s, want partial", phase) + } + + beforeWithUnrelated := before + beforeWithUnrelated.Commands = append([]Command(nil), before.Commands...) + beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags = append( + beforeWithUnrelated.Commands[len(beforeWithUnrelated.Commands)-1].LocalFlags, + Flag{Name: "unrelated", Type: "string"}, + ) + afterWithUnrelated := after + afterWithUnrelated.Commands = append([]Command(nil), after.Commands...) + afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags = append( + afterWithUnrelated.Commands[len(afterWithUnrelated.Commands)-1].LocalFlags, + Flag{Name: "unrelated", Type: "string", Required: true}, + ) + report, err = CompareAllWithFlagMigrations( + afterWithUnrelated, + map[string]Snapshot{"merge-base": beforeWithUnrelated, "stable": beforeWithUnrelated}, + pending, + consumed, + ) + if err != nil || report.Compatible || !hasFlagChange(report.Comparisons[0].Blocking, "flag_became_required", migration.Command, "unrelated") { + t.Fatalf("unrelated requiredness change was hidden: report=%#v err=%v", report, err) + } + + empty := FlagMigrationManifest{Version: FlagMigrationManifestVersion, Migrations: []FlagMigration{}} + if _, err := CompareAllWithFlagMigrations( + after, + map[string]Snapshot{"merge-base": before, "stable": before}, + empty, + pending, + ); err == nil || !strings.Contains(err.Error(), "cannot authorize its own interface change") { + t.Fatalf("candidate self-authorization error = %v", err) + } +} + +func requirednessMigrationSnapshot(migration FlagMigration, after bool) Snapshot { + state := migration.Flag.Before + if after { + state = migration.Flag.After + } + flag := Flag{ + Name: migration.Flag.Name, + Type: state.Type, + Required: state.Required, + Hidden: state.Hidden, + Shorthand: state.Shorthand, + NoOpt: state.NoOpt, + } + command := testCommandWithFlagScopes(migration.Command, nil, nil) + if state.Scope == "inherited" { + command.InheritedFlags = []Flag{flag} + } else { + command.LocalFlags = []Flag{flag} + } + return testSnapshot(testCommand("dws"), command) +} + func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactContracts(t *testing.T) { manifest, err := ReadFlagMigrationManifest(strings.NewReader(validFlagMigrationManifestJSON)) if err != nil { @@ -282,6 +525,22 @@ func TestCrossPlatformCoverageFlagMigrationManifestRejectsDuplicateAndInexactCon if err == nil || !strings.Contains(err.Error(), "trailing") { t.Fatalf("trailing JSON error = %v", err) } + + requiredness, err := ReadFlagMigrationManifest(strings.NewReader(validRequirednessMigrationManifestJSON)) + if err != nil { + t.Fatal(err) + } + overlap := manifest + overlap.Migrations = append([]FlagMigration(nil), manifest.Migrations...) + rename := overlap.Migrations[0] + rename.Command = requiredness.Migrations[0].Command + rename.Canonical.Name = requiredness.Migrations[0].Flag.Name + rename.Legacy.After.AliasOf = rename.Canonical.Name + overlap.Migrations[0] = rename + overlap.Migrations = append(overlap.Migrations, requiredness.Migrations[0]) + if err := overlap.Validate(); err == nil || !strings.Contains(err.Error(), "overlaps rename migration") { + t.Fatalf("requiredness and rename overlap error = %v", err) + } } func TestCrossPlatformCoverageAuthorizeFlagMigrationsReturnsExactBaseOwnedApproval(t *testing.T) { diff --git a/internal/interfacesnapshot/snapshot.go b/internal/interfacesnapshot/snapshot.go index 408805d3..11a7bf46 100644 --- a/internal/interfacesnapshot/snapshot.go +++ b/internal/interfacesnapshot/snapshot.go @@ -22,12 +22,13 @@ import ( "sort" "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate" "github.com/spf13/cobra" "github.com/spf13/pflag" ) -const SchemaVersion = 2 +const SchemaVersion = 3 // FlagAliasOfAnnotation records a framework-originated reviewed relationship // between a retained compatibility flag and an exact canonical Cobra flag. @@ -66,13 +67,14 @@ type Snapshot struct { // Command contains compatibility-relevant command metadata. Path always // includes the root command name (for example, "dws chat message send"). type Command struct { - Path string `json:"path"` - Runnable bool `json:"runnable"` - Hidden bool `json:"hidden"` - Deprecated string `json:"deprecated,omitempty"` - Aliases []string `json:"aliases"` - LocalFlags []Flag `json:"local_flags"` - InheritedFlags []Flag `json:"inherited_flags"` + Path string `json:"path"` + Runnable bool `json:"runnable"` + Hidden bool `json:"hidden"` + Deprecated string `json:"deprecated,omitempty"` + Aliases []string `json:"aliases"` + LocalFlags []Flag `json:"local_flags"` + InheritedFlags []Flag `json:"inherited_flags"` + BoolConstParams map[string]bool `json:"bool_const_params,omitempty"` } // Flag contains the stable pflag contract visible at a command node. @@ -126,14 +128,16 @@ func Capture(root *cobra.Command) Snapshot { } aliases = compactSorted(aliases) + boolConstParams := corecmd.InterfaceBoolConstParams(cmd) snapshot.Commands = append(snapshot.Commands, Command{ - Path: path, - Runnable: cmd.Runnable(), - Hidden: cmd.Hidden, - Deprecated: strings.TrimSpace(cmd.Deprecated), - Aliases: aliases, - LocalFlags: captureFlags(cmd.LocalFlags()), - InheritedFlags: captureFlags(cmd.InheritedFlags()), + Path: path, + Runnable: cmd.Runnable(), + Hidden: cmd.Hidden, + Deprecated: strings.TrimSpace(cmd.Deprecated), + Aliases: aliases, + LocalFlags: captureFlags(cmd.LocalFlags()), + InheritedFlags: captureFlags(cmd.InheritedFlags()), + BoolConstParams: boolConstParams, }) children := append([]*cobra.Command(nil), cmd.Commands()...) @@ -198,6 +202,9 @@ func (s Snapshot) Validate() error { return fmt.Errorf("interface snapshot contains duplicate command path %q", command.Path) } seenCommands[command.Path] = true + if command.BoolConstParams != nil && len(command.BoolConstParams) == 0 { + return fmt.Errorf("command %q must omit empty bool_const_params", command.Path) + } if err := validateFlags(command.Path, "local", command.LocalFlags); err != nil { return err } diff --git a/internal/interfacesnapshot/snapshot_test.go b/internal/interfacesnapshot/snapshot_test.go index eb27274e..2bb6324d 100644 --- a/internal/interfacesnapshot/snapshot_test.go +++ b/internal/interfacesnapshot/snapshot_test.go @@ -17,6 +17,7 @@ import ( "bytes" "encoding/json" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -98,16 +99,105 @@ func TestCrossPlatformCoverageCaptureUsesStableNoiseRulesAndFlagScopes(t *testin func TestCrossPlatformCoverageReadRejectsUnknownSnapshotFields(t *testing.T) { input := bytes.NewBufferString(`{ - "schema_version": 2, - "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, - "commands": [], - "future_field": true + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [], + "future_field": true }`) if _, err := Read(input); err == nil { t.Fatal("Read accepted an unknown field") } } +func TestCrossPlatformCoverageCaptureRoundTripsFrameworkBoolConstParams(t *testing.T) { + root := &cobra.Command{Use: "dws"} + leaf := corecmd.New(corecmd.Spec{ + Use: "send", + ConstParams: map[string]any{"convThreadEnabled": true, "precheckOnly": false}, + Invoke: func(*corecmd.Ctx, map[string]any) error { return nil }, + }) + root.AddCommand(leaf) + + snapshot := Capture(root) + got := commandIndex(snapshot)["dws send"].BoolConstParams + want := map[string]bool{"convThreadEnabled": true, "precheckOnly": false} + if !reflect.DeepEqual(got, want) { + t.Fatalf("captured bool ConstParams = %#v, want %#v", got, want) + } + + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write: %v", err) + } + decoded, err := Read(bytes.NewReader(encoded.Bytes())) + if err != nil { + t.Fatalf("Read: %v", err) + } + if got := commandIndex(decoded)["dws send"].BoolConstParams; !reflect.DeepEqual(got, want) { + t.Fatalf("round-tripped bool ConstParams = %#v, want %#v", got, want) + } +} + +func TestCrossPlatformCoverageCaptureIgnoresHandwrittenLegacyConstParamsAnnotations(t *testing.T) { + root := &cobra.Command{Use: "dws"} + direct := &cobra.Command{ + Use: "direct", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + "dws.compat.bool_const_params": `{"forged":true}`, + "dws.compat.const_params_origin": "corecmd.const_params.v1", + }, + } + businessConstructor := func() *cobra.Command { + payloadKey := strings.Join([]string{"dws.compat.bool_", "const_params"}, "") + originKey := strings.Join([]string{"dws.compat.const_", "params_origin"}, "") + origin := strings.Join([]string{"corecmd.const_", "params.v1"}, "") + return &cobra.Command{ + Use: "business", + Run: func(*cobra.Command, []string) {}, + Annotations: map[string]string{ + payloadKey: `{"forged":true}`, + originKey: origin, + }, + } + } + root.AddCommand(direct, businessConstructor()) + + snapshot := Capture(root) + if err := snapshot.Validate(); err != nil { + t.Fatalf("handwritten legacy annotations affected snapshot validity: %v", err) + } + for _, path := range []string{"dws direct", "dws business"} { + if got := commandIndex(snapshot)[path].BoolConstParams; got != nil { + t.Fatalf("%s forged bool ConstParams evidence = %#v", path, got) + } + } + var encoded bytes.Buffer + if err := Write(&encoded, snapshot); err != nil { + t.Fatalf("Write snapshot without forged evidence: %v", err) + } +} + +func TestCrossPlatformCoverageReadRejectsMalformedBoolConstParams(t *testing.T) { + for _, payload := range []string{`{"fixed":"true"}`, `{}`} { + input := bytes.NewBufferString(`{ + "schema_version": 3, + "rules": {"excluded_command_subtrees": [], "excluded_flags": []}, + "commands": [{ + "path": "dws send", + "runnable": true, + "aliases": [], + "local_flags": [], + "inherited_flags": [], + "bool_const_params": ` + payload + ` + }] + }`) + if _, err := Read(input); err == nil { + t.Fatalf("Read accepted malformed bool_const_params %s", payload) + } + } +} + func TestCrossPlatformCoverageCaptureRoundTripsFrameworkFlagAlias(t *testing.T) { if FlagAliasOfAnnotation != runtimeannotate.AnnotationFlagAliasOf { t.Fatalf( diff --git a/internal/shortcut/aitable/aitable.go b/internal/shortcut/aitable/aitable.go index ac7443da..c5c20534 100644 --- a/internal/shortcut/aitable/aitable.go +++ b/internal/shortcut/aitable/aitable.go @@ -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 { diff --git a/internal/shortcut/aitable/base_composite_e2e_test.go b/internal/shortcut/aitable/base_composite_e2e_test.go index 20c6965d..9df47ae6 100644 --- a/internal/shortcut/aitable/base_composite_e2e_test.go +++ b/internal/shortcut/aitable/base_composite_e2e_test.go @@ -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++ { diff --git a/internal/shortcut/aitable/base_copy.go b/internal/shortcut/aitable/base_copy.go new file mode 100644 index 00000000..bc569a68 --- /dev/null +++ b/internal/shortcut/aitable/base_copy.go @@ -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 +} diff --git a/internal/shortcut/aitable/primary_doc.go b/internal/shortcut/aitable/primary_doc.go new file mode 100644 index 00000000..52d2a662 --- /dev/null +++ b/internal/shortcut/aitable/primary_doc.go @@ -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 +} diff --git a/internal/shortcut/aitable/record_batch.go b/internal/shortcut/aitable/record_batch.go index 63f7d92f..7ff35ebd 100644 --- a/internal/shortcut/aitable/record_batch.go +++ b/internal/shortcut/aitable/record_batch.go @@ -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)) diff --git a/internal/shortcut/aitable/record_coverage_gap_test.go b/internal/shortcut/aitable/record_coverage_gap_test.go index 00d89975..253e2e90 100644 --- a/internal/shortcut/aitable/record_coverage_gap_test.go +++ b/internal/shortcut/aitable/record_coverage_gap_test.go @@ -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"}}} diff --git a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go index 2304cf6c..653a80b5 100644 --- a/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go +++ b/internal/shortcut/aitable/record_upsert_by_key_e2e_test.go @@ -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 != "" { diff --git a/internal/shortcut/aitable/remaining_coverage_gap_test.go b/internal/shortcut/aitable/remaining_coverage_gap_test.go index 2b834f68..3f95b40e 100644 --- a/internal/shortcut/aitable/remaining_coverage_gap_test.go +++ b/internal/shortcut/aitable/remaining_coverage_gap_test.go @@ -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 diff --git a/internal/shortcut/aitable/view_preset.go b/internal/shortcut/aitable/view_preset.go index 6a16aebc..66ea741f 100644 --- a/internal/shortcut/aitable/view_preset.go +++ b/internal/shortcut/aitable/view_preset.go @@ -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 { diff --git a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go index ee437148..450964cd 100644 --- a/internal/shortcut/aitable/view_workflow_composite_e2e_test.go +++ b/internal/shortcut/aitable/view_workflow_composite_e2e_test.go @@ -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", diff --git a/internal/shortcut/aitabletarget/resolver.go b/internal/shortcut/aitabletarget/resolver.go index 25baf098..882d4e3f 100644 --- a/internal/shortcut/aitabletarget/resolver.go +++ b/internal/shortcut/aitabletarget/resolver.go @@ -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") diff --git a/internal/shortcut/aitabletarget/resolver_test.go b/internal/shortcut/aitabletarget/resolver_test.go index 8a011a2a..68c989b5 100644 --- a/internal/shortcut/aitabletarget/resolver_test.go +++ b/internal/shortcut/aitabletarget/resolver_test.go @@ -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": "项目管理归档"}, diff --git a/internal/shortcut/attendance/attendance.go b/internal/shortcut/attendance/attendance.go index 067f922c..5d62242c 100644 --- a/internal/shortcut/attendance/attendance.go +++ b/internal/shortcut/attendance/attendance.go @@ -26,6 +26,7 @@ package attendance import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -34,6 +35,7 @@ import ( "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" ) const ( @@ -86,7 +88,7 @@ func flexDateTime(s string) (string, error) { } // dateToMillis parses YYYY-MM-DD / datetime; when endOfDay is set a bare date -// resolves to 23:59:59 (mirrors helper parseDateToTimestamp "end" behaviour). +// resolves to the final millisecond before the next local calendar day. func dateToMillis(s string, endOfDay bool) (int64, error) { s = strings.TrimSpace(s) if t, err := time.ParseInLocation("2006-01-02 15:04:05", s, time.Local); err == nil { @@ -94,7 +96,7 @@ func dateToMillis(s string, endOfDay bool) (int64, error) { } if t, err := time.ParseInLocation("2006-01-02", s, time.Local); err == nil { if endOfDay { - return t.Add(23*time.Hour + 59*time.Minute + 59*time.Second).UnixMilli(), nil + return t.AddDate(0, 0, 1).Add(-time.Millisecond).UnixMilli(), nil } return t.UnixMilli(), nil } @@ -197,14 +199,43 @@ var CheckResult = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔,最多 100 人", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, - {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "分页偏移量,默认 0"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "分页大小,1-1000,默认 100"}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,最多 100 个,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + {Name: "offset", Type: shortcut.FlagInt, Default: "0", Desc: "--offset 不能小于 0,默认 0"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "--limit 必须大于 0 且不超过 1000,默认 100"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复,最多 100 个"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 1000"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"offset"}, Description: "--offset 不能小于 0"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 100); err != nil { + return err + } + if err := attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")); err != nil { + return err + } + if limit := rt.Int("limit"); limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if rt.Int("offset") < 0 { + return fmt.Errorf("--offset 不能小于 0") + } + return nil }, Tips: []string{`dws attendance +check-result --users userId1,userId2 --start 2026-04-01 --end 2026-04-30 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { + limit, offset := rt.Int("limit"), rt.Int("offset") + if limit < 1 || limit > 1000 { + return fmt.Errorf("--limit 必须在 1 到 1000 之间") + } + if offset < 0 { + return fmt.Errorf("--offset 不能小于 0") + } from, err := dayDateTime(rt.Str("start")) if err != nil { return err @@ -213,15 +244,40 @@ var CheckResult = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_result", map[string]any{ + params := map[string]any{ "QueryCheckResultRequest": map[string]any{ "userIds": rt.StrSlice("users"), "workDateFrom": from, "workDateTo": to, - "offset": rt.Int("offset"), - "limit": rt.Int("limit"), + "offset": offset, + "limit": limit, }, - }) + } + data, err := rt.CallMCPData(serverWukong, "query_check_result", params) + if err != nil { + return err + } + records, err := responsecheck.RequireObjectCollection(data, serverWukong+"/query_check_result", "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(records, serverWukong+"/query_check_result", "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(records, serverWukong+"/query_check_result", rt.StrSlice("users"), "userId", "workDate", startMillis, endMillis); err != nil { + return err + } + complete := len(records) < limit + extra := map[string]any{"limit": limit} + nextToken := "" + if !complete { + nextOffset := offset + len(records) + extra["nextOffset"] = nextOffset + nextToken = strconv.Itoa(nextOffset) + } + return attendanceOutputCollection(rt, "records", records, complete, extra, true, nextToken) }, } @@ -259,9 +315,20 @@ var CheckRecord = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD,跨度不超过 1 个月", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,不能早于 --start,且与 --start 跨度不超过 1 个月"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + return attendanceValidateMonthRange(rt.Str("start"), rt.Str("end")) }, Tips: []string{`dws attendance +check-record --users userId1 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -273,13 +340,30 @@ var CheckRecord = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("query_check_record", map[string]any{ + data, err := rt.CallMCPData(serverWukong, "query_check_record", map[string]any{ "QueryCheckRecordRequest": map[string]any{ "userIds": rt.StrSlice("users"), "checkDateFrom": from, "checkDateTo": to, }, }) + if err != nil { + return err + } + operation := serverWukong + "/query_check_record" + items, err := responsecheck.RequireObjectCollection(data, operation, "result") + if err != nil { + return err + } + if err := attendanceValidatePositiveIntegerIDs(items, operation, "id"); err != nil { + return err + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + if err := attendanceValidateUserAndTimeBinding(items, operation, rt.StrSlice("users"), "userId", "userCheckTime", startMillis, endMillis); err != nil { + return err + } + return attendanceOutputCollection(rt, "records", items, true, nil, false, "") }, } @@ -319,10 +403,47 @@ var ListApprove = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "types", Type: shortcut.FlagStringSlice, Desc: "审批类型,逗号分隔:overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "起始日期 YYYY-MM-DD", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "types", Type: shortcut.FlagStringSlice, Desc: "--types 不能为空,映射后的审批类型不能重复;overtime/加班、trip/travel/出差/外出、leave/请假、patch/补卡", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"types"}, Description: "--types 不能为空,映射后的审批类型不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + seen := map[int]struct{}{} + for _, value := range rt.StrSlice("types") { + mapped, ok := approveTypeMapping[strings.ToLower(strings.TrimSpace(value))] + if !ok { + return fmt.Errorf("无效的审批类型: %s", value) + } + if _, duplicate := seen[mapped]; duplicate { + return fmt.Errorf("审批类型不能重复") + } + seen[mapped] = struct{}{} + } + if len(seen) == 0 { + return fmt.Errorf("--types 不能为空") + } + return nil }, Tips: []string{`dws attendance +list-approve --users userId1 --types overtime,leave --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -346,14 +467,25 @@ var ListApprove = shortcut.Shortcut{ } bizTypes = append(bizTypes, bizType) } - return rt.CallMCP("query_user_approve", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_user_approve", map[string]any{ "QueryUserApproveRequest": map[string]any{ "userIds": rt.StrSlice("users"), "bizTypes": bizTypes, "fromDate": from, "toDate": to, }, - }) + }, "approvals", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/query_user_approve", "id"); err != nil { + return err + } + requestedTypes := make(map[int]struct{}, len(bizTypes)) + for _, value := range bizTypes { + requestedTypes[value] = struct{}{} + } + startMillis, _ := dayMillis(rt.Str("start")) + endMillis, _ := dateToMillis(rt.Str("end"), true) + return attendanceValidateApprovalBinding(items, serverWukong+"/query_user_approve", rt.StrSlice("users"), requestedTypes, startMillis, endMillis) + }, "result") }, } @@ -405,9 +537,11 @@ var GetApproveTemplate = shortcut.Shortcut{ default: return fmt.Errorf("无效的审批类型: %s,支持: repair-check/补卡、leave/请假、overtime/加班、travel/外出、out/出差,或 REPAIR_CHECK/LEAVE/OVERTIME/TRAVEL/OUT", input) } - return rt.CallMCP("query_at_approve_template", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "query_at_approve_template", map[string]any{ "approveType": approveType, - }) + }, "templates", true, nil, func(items []map[string]any) error { + return attendanceValidateApproveTemplates(items, serverWukong+"/query_at_approve_template", approveType) + }, "result") }, } @@ -450,9 +584,31 @@ var GetSchedule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "users", Type: shortcut.FlagStringSlice, Desc: "用户 userId 列表,逗号分隔", Required: true}, - {Name: "start", Type: shortcut.FlagString, Desc: "开始日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束日期 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "users", Type: shortcut.FlagStringSlice, Desc: "--users 不能为空,用户 ID 不能重复,逗号分隔", Required: true}, + {Name: "start", Type: shortcut.FlagString, Desc: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start", Required: true}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"users"}, Description: "--users 不能为空,用户 ID 不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start"}, Description: "--start 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"end"}, Description: "--end 必须是 YYYY-MM-DD 或 yyyy-MM-dd HH:mm:ss,且不能早于 --start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("users"), 0); err != nil { + return err + } + start, err := dateToMillis(rt.Str("start"), false) + if err != nil { + return err + } + end, err := dateToMillis(rt.Str("end"), true) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil }, Tips: []string{`dws attendance +get-schedule --users user001,user002 --start 2026-04-01 --end 2026-04-30`}, Execute: func(rt *shortcut.RuntimeContext) error { @@ -464,13 +620,18 @@ var GetSchedule = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("getScheduleByRange", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "getScheduleByRange", map[string]any{ "GetScheduleByRangeRequest": map[string]any{ "userIdList": rt.StrSlice("users"), "workDateBegin": begin, "workDateEnd": end, }, - }) + }, "schedules", true, nil, func(items []map[string]any) error { + if err := attendanceValidatePositiveIntegerIDs(items, serverWukong+"/getScheduleByRange", "id"); err != nil { + return err + } + return attendanceValidateUserAndTimeBinding(items, serverWukong+"/getScheduleByRange", rt.StrSlice("users"), "userId", "workDate", begin, end) + }, "result") }, } @@ -560,18 +721,21 @@ var SearchClass = shortcut.Shortcut{ Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "班次名称关键字,模糊搜索"}, {Name: "filter-type", Type: shortcut.FlagString, Enum: []string{"ALL", "MINE_OWN"}, Desc: "班次类型:ALL 全部 / MINE_OWN 我负责的"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 200"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-class --query "早班" --filter-type MINE_OWN`}, Execute: func(rt *shortcut.RuntimeContext) error { - pageQuery := map[string]any{} - if v := rt.Int("page"); v > 0 { - pageQuery["pageIndex"] = v - } - if v := rt.Int("limit"); v > 0 { - pageQuery["pageSize"] = v + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } + pageQuery := map[string]any{"pageIndex": page, "pageSize": limit} shiftParam := map[string]any{} if v := rt.Str("query"); v != "" { shiftParam["searchName"] = v @@ -590,69 +754,64 @@ var SearchClass = shortcut.Shortcut{ if err != nil { return err } - classes := searchClassProject(data) - return rt.Output(map[string]any{"count": len(classes), "classes": classes}) + classes, err := searchClassProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_class_list", page, limit, len(classes)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "classes", classes, complete, extra, true, nextToken) }, } // searchClassProject reshapes the raw get_class_list response into a clean class // list ({classId, name, ownerName}) — clean output projection. Both // the list container and per-item field names are probed defensively across -// candidate keys, so an unknown/empty shape yields an empty list rather than a -// crash or fabricated data. -func searchClassProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "classList", "shiftList", "list", "items") +// result.items is the reviewed wire path. Missing/wrong collections and bad +// rows fail closed instead of becoming an empty list. +func searchClassProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_class_list", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, item := range raw { + m := item // get_class_list wraps each shift's identity/label under shiftVO // (item = {"shiftVO": {id, name, ...}}); unwrap it first, otherwise every // row projects empty and the whole command silently returns nothing. - if vo, ok := m["shiftVO"].(map[string]any); ok { + if value, present := m["shiftVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "malformed_item", fmt.Sprintf("result.items[%d].shiftVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "classId", "class_id"); ok { - row["classId"] = v + v, ok := attendanceFirst(m, "id", "classId", "class_id") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(serverWukong+"/get_class_list", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少班次 ID", index)) } + row["classId"] = identity if v, ok := attendanceFirst(m, "name", "className", "class_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "ownerName", "owner_name", "owner"); ok { row["ownerName"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out -} - -// attendanceResolveList locates the list payload inside a response, tolerating a -// bare top-level array under a common container key or nesting one level deeper -// under result/data. extraKeys are probed ahead of the generic containers. -func attendanceResolveList(data map[string]any, extraKeys ...string) []any { - keys := append(append([]string{}, extraKeys...), "result", "data", "list", "items") - for _, key := range keys { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range append(append([]string{}, extraKeys...), "list", "items", "result", "data") { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } + if err := attendanceValidatePositiveIntegerIDs(out, serverWukong+"/get_class_list", "classId"); err != nil { + return nil, err } - return []any{} + return out, nil } // attendanceFirst returns the first present candidate key's value. @@ -673,14 +832,58 @@ var GetClass = shortcut.Shortcut{ Description: "根据班次 ID 查询班次详情", Intent: "当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。", Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", + Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Identity: contract.ToolIdentitySpec{ + ProductID: "attendance", + Name: "shortcut_get_class", + CanonicalPath: "attendance.shortcut_get_class", + CLIPath: "attendance +get-class", + PrimaryCLIPath: "attendance +get-class", + }, + Description: "根据班次 ID 查询班次详情", + Interface: &contract.InterfaceSpec{ + Mode: "composite", + Availability: "available", + Reason: "Reviewed built-in shortcut adapter: the executable CLI owns strict response validation and output projection for this attendance detail operation.", + }, + Selection: contract.SelectionSpec{ + AgentSummary: "根据班次 ID 查询班次详情", + UseWhen: []string{"当你已知某个班次的 ID、想查看它的完整配置(上下班时间段、休息时段、负责人等)时使用,例如更新班次前先读取现状;输入班次 ID,返回该班次的详细设置。若不知道班次 ID,先用 +search-class 搜索。"}, + AvoidWhen: []string{"不知道班次 ID 时先用 +search-class;不要用本读取命令代替创建或更新班次。"}, + Examples: []string{"dws attendance +get-class --class-id 12345"}, + }, + }, Flags: []shortcut.Flag{ - {Name: "class-id", Type: shortcut.FlagInt, Desc: "班次 ID", Required: true}, + {Name: "class-id", Type: shortcut.FlagInt, Desc: "--class-id 必须大于 0,表示班次 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"class-id"}, Description: "--class-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("class-id") <= 0 { + return fmt.Errorf("--class-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-class --class-id 1170996821`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_class_detail", map[string]any{ - "classId": int64(rt.Int("class-id")), - }) + requested := int64(rt.Int("class-id")) + data, err := rt.CallMCPData(serverWukong, "get_class_detail", map[string]any{"classId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_class_detail") + if err != nil { + return err + } + identityValue, ok := attendanceNestedValue(value, "shiftVO", "id") + identity, valid := attendancePositiveInteger(identityValue) + if !ok || !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_class_detail", "object_identity_mismatch", "响应 shiftVO.id 与请求 classId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -784,9 +987,9 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Description: "根据补卡规则主键 ID 查询补卡规则详情", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据补卡规则主键 ID 查询补卡规则详情", @@ -800,7 +1003,7 @@ var GetAdjustmentRule = shortcut.Shortcut{ }, Tips: []string{`dws attendance +get-adjustment-rule --adjustment-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_adjustment_rule_detail", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_adjustment_rule_detail", map[string]any{ "adjustmentId": int64(rt.Int("adjustment-id")), }) }, @@ -812,7 +1015,7 @@ var SearchAdjustmentRule = shortcut.Shortcut{ Command: "+search-adjustment-rule", Product: serverWukong, Description: "查询当前用户可管理的补卡规则列表", - Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。", + Intent: "当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。", Risk: shortcut.RiskRead, Safety: contract.SafetySpec{ Effect: "read", Risk: "low", @@ -834,29 +1037,30 @@ var SearchAdjustmentRule = shortcut.Shortcut{ }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的补卡规则列表", - UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则、以便拿到规则 ID 做进一步查看时使用;可选按规则名关键字模糊搜索并分页,返回补卡规则列表。要看某条规则的完整内容用 +get-adjustment-rule。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, + UseWhen: []string{"当你要浏览或按名称查找当前用户可管理的补卡规则时使用;可选按规则名关键字模糊搜索并分页,返回带稳定候选 ID 的补卡规则列表。当前下游详情接口对搜索得到的 ID 返回空 result,因此本命令不承诺可继续读取详情。"}, + AvoidWhen: []string{"需要完整补卡规则详情时应报告当前下游详情能力 unavailable,不要把搜索摘要或空 result 当作详情成功"}, Examples: []string{"dws attendance +search-adjustment-rule --query \"标准\" --page 1 --limit 50"}, }, }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "补卡规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-adjustment-rule --query "标准" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -866,44 +1070,62 @@ var SearchAdjustmentRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_adjustment_rule", "result.adjustmentList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_adjustment_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } // searchRuleProject reshapes the raw get_adjustment_rule / get_overtime_rule -// response into a clean rule list ({ruleId, name}) — output-projection fidelity -// for clean output. The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchRuleProject(data map[string]any) []map[string]any { +// response into a clean rule list ({ruleId, name}). Each caller supplies the +// single reviewed collection path; missing/wrong collections and malformed +// rows fail closed instead of becoming an empty list. +func searchRuleProject(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { // get_overtime_rule nests the list under result.atRuleList and // get_adjustment_rule under result.adjustmentList; both wrap each rule's // identity/label under entityVO. Probe those container keys AND unwrap // entityVO, otherwise +search-overtime-rule / +search-adjustment-rule // silently return empty despite the backend returning rules. - raw := attendanceResolveList(data, "atRuleList", "adjustmentList", "ruleList", "rules", "list", "items") + raw, err := responsecheck.RequireObjectCollection(data, operation, paths...) + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if vo, ok := m["entityVO"].(map[string]any); ok { + for index, item := range raw { + m := item + if value, present := m["entityVO"]; present { + vo, ok := value.(map[string]any) + if !ok || len(vo) == 0 { + return nil, responsecheck.Error(operation, "malformed_item", fmt.Sprintf("规则结果第 %d 项 entityVO 不是非空对象", index)) + } m = vo } row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId"); ok { - row["ruleId"] = v + v, ok := attendanceFirst(m, "id", "ruleId", "rule_id", "adjustmentId", "overtimeId") + identity, validIdentity := attendancePositiveInteger(v) + if !ok || !validIdentity { + return nil, responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("规则结果第 %d 项缺少规则 ID", index)) } + row["ruleId"] = identity if v, ok := attendanceFirst(m, "name", "ruleName", "rule_name"); ok { row["name"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + if err := attendanceValidatePositiveIntegerIDs(out, operation, "ruleId"); err != nil { + return nil, err + } + return out, nil } // ── overtime rule ─────────────────────────────────────────── @@ -942,13 +1164,31 @@ var GetOvertimeRule = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "加班规则主键 ID", Required: true}, + {Name: "overtime-id", Type: shortcut.FlagInt, Desc: "--overtime-id 必须大于 0,表示加班规则主键 ID", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"overtime-id"}, Description: "--overtime-id 必须大于 0"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("overtime-id") <= 0 { + return fmt.Errorf("--overtime-id 必须大于 0") + } + return nil }, Tips: []string{`dws attendance +get-overtime-rule --overtime-id 12345`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_overtime_rule_detail", map[string]any{ - "overtimeId": int64(rt.Int("overtime-id")), - }) + requested := int64(rt.Int("overtime-id")) + data, err := rt.CallMCPData(serverWukong, "get_overtime_rule_detail", map[string]any{"overtimeId": requested}) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/get_overtime_rule_detail") + if err != nil { + return err + } + identity, valid := attendancePositiveInteger(value["id"]) + if !valid || identity != requested { + return responsecheck.Error(serverWukong+"/get_overtime_rule_detail", "object_identity_mismatch", "响应 id 与请求 overtimeId 不一致") + } + return rt.Output(map[string]any{"value": value}) }, } @@ -987,22 +1227,23 @@ var SearchOvertimeRule = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "query", Type: shortcut.FlagString, Desc: "加班规则名称关键字,模糊搜索"}, - {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "页码,从 1 开始"}, - {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,200 以内"}, + {Name: "page", Type: shortcut.FlagInt, Default: "1", Desc: "--page 必须大于 0,从 1 开始"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "--limit 必须大于 0 且不超过 200"}, }, - Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须大于 0 且不超过 200"}, + }, + Validate: attendanceValidatePageRequest, + Tips: []string{`dws attendance +search-overtime-rule --query "节假日" --page 1 --limit 50`}, Execute: func(rt *shortcut.RuntimeContext) error { param := map[string]any{} if v := rt.Str("query"); v != "" { param["name"] = v } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } param["currentPage"] = page param["pageSize"] = limit @@ -1012,8 +1253,19 @@ var SearchOvertimeRule = shortcut.Shortcut{ if err != nil { return err } - rules := searchRuleProject(data) - return rt.Output(map[string]any{"count": len(rules), "rules": rules}) + rules, err := searchRuleProject(data, serverWukong+"/get_overtime_rule", "result.atRuleList") + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_overtime_rule", page, limit, len(rules)) + if err != nil { + return err + } + nextToken := "" + if !complete { + nextToken = strconv.Itoa(page + 1) + } + return attendanceOutputCollection(rt, "rules", rules, complete, extra, true, nextToken) }, } @@ -1041,9 +1293,9 @@ var SearchGroup = shortcut.Shortcut{ }, Description: "查询当前用户可管理的考勤组列表", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "查询当前用户可管理的考勤组列表", @@ -1079,13 +1331,9 @@ var SearchGroup = shortcut.Shortcut{ searchParam["queryPositionAndWifiNames"] = false searchParam["queryBleDeviceList"] = false } - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 + page, limit, err := attendancePageInput(rt) + if err != nil { + return err } data, err := rt.CallMCPData(serverWukong, "get_simple_groups", map[string]any{ "param": searchParam, @@ -1097,39 +1345,44 @@ var SearchGroup = shortcut.Shortcut{ if err != nil { return err } - groups := searchGroupProject(data) - return rt.Output(map[string]any{"count": len(groups), "groups": groups}) + groups, err := searchGroupProject(data) + if err != nil { + return err + } + complete, extra, err := attendancePageEvidence(data, serverWukong+"/get_simple_groups", page, limit, len(groups)) + if err != nil { + return err + } + return rt.Output(attendanceCollectionPayload("groups", groups, complete, extra)) }, } // searchGroupProject reshapes the raw get_simple_groups response into a clean // attendance-group list ({groupId, name, type}) — clean output projection. -// The list container and per-item field names are probed defensively -// across candidate keys, so an unknown/empty shape yields an empty list rather -// than a crash or fabricated data. -func searchGroupProject(data map[string]any) []map[string]any { - raw := attendanceResolveList(data, "groupList", "groups", "list", "items") +// The exact result.items collection and each row identity are required, so an +// unknown shape cannot masquerade as a legitimate empty search result. +func searchGroupProject(data map[string]any) ([]map[string]any, error) { + raw, err := responsecheck.RequireObjectCollection(data, serverWukong+"/get_simple_groups", "result.items") + if err != nil { + return nil, err + } out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } + for index, m := range raw { row := map[string]any{} - if v, ok := attendanceFirst(m, "id", "groupId", "group_id"); ok { - row["groupId"] = v + v, ok := attendanceFirst(m, "id", "groupId", "group_id") + if !ok || v == nil { + return nil, responsecheck.Error(serverWukong+"/get_simple_groups", "missing_item_identity", fmt.Sprintf("result.items[%d] 缺少考勤组 ID", index)) } + row["groupId"] = v if v, ok := attendanceFirst(m, "name", "groupName", "group_name"); ok { row["name"] = v } if v, ok := attendanceFirst(m, "type", "groupType", "group_type"); ok { row["type"] = v } - if len(row) > 0 { - out = append(out, row) - } + out = append(out, row) } - return out + return out, nil } // GetGroup 根据考勤组 ID 查询考勤组全量信息(get_group_detail)。 @@ -1379,7 +1632,7 @@ var GetSummary = shortcut.Shortcut{ if err != nil { return err } - return rt.CallMCP("get_user_attendance_summary", map[string]any{ + return attendanceCallObject(rt, serverWukong, "get_user_attendance_summary", map[string]any{ "userId": rt.Str("user"), "queryDate": queryDate, "statsType": rt.Str("stats-type"), @@ -1427,19 +1680,80 @@ var GetSelfSetting = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "setting-scene", Type: shortcut.FlagString, Enum: []string{"checkRemind", "fastCheck", "checkResultNotify", "lackRemind", "personalAttendStatNotify", "bossAttendStatNotify"}, Desc: "查询设置项场景", Required: true}, - {Name: "user", Type: shortcut.FlagString, Desc: "查询用户 userId", Required: true}, + {Name: "user", Type: shortcut.FlagString, Desc: "--user 不能为空,表示查询用户 userId", Required: true}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + return nil }, Tips: []string{`dws attendance +get-self-setting --setting-scene checkRemind --user USER_ID`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("query_self_setting", map[string]any{ + userID := rt.Str("user") + scene := rt.Str("setting-scene") + data, err := rt.CallMCPData(serverWukong, "query_self_setting", map[string]any{ "RuleMcpQuerySelfSettingRequest": map[string]any{ - "settingScene": rt.Str("setting-scene"), - "userId": rt.Str("user"), + "settingScene": scene, + "userId": userID, }, }) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, serverWukong+"/query_self_setting") + if err != nil { + return err + } + if err := attendanceValidateSelfSetting(value, userID, scene); err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) }, } +func attendanceValidateSelfSetting(value map[string]any, requestedUser, scene string) error { + operation := serverWukong + "/query_self_setting" + requestedUser = strings.TrimSpace(requestedUser) + if requestedUser == "" { + return responsecheck.Error(operation, "object_identity_mismatch", "请求 userId 不能为空") + } + returnedUser, ok := value["userId"].(string) + if !ok || returnedUser == "" || returnedUser != requestedUser { + return responsecheck.Error(operation, "object_identity_mismatch", "响应 userId 与请求用户不一致") + } + type sceneContract struct { + field string + valid func(any) bool + } + contracts := map[string]sceneContract{ + "checkRemind": {field: "checkRemindSetting", valid: func(v any) bool { _, ok := v.(map[string]any); return ok }}, + "fastCheck": {field: "fastCheckLateNeedConfirm", valid: func(v any) bool { _, ok := v.(bool); return ok }}, + "checkResultNotify": {field: "checkResultMsg", valid: attendanceSettingInteger}, + "lackRemind": {field: "lackRemindUser", valid: attendanceSettingInteger}, + "personalAttendStatNotify": {field: "personDailyReportSwitch", valid: attendanceSettingInteger}, + "bossAttendStatNotify": {field: "bossMonthReportType", valid: attendanceSettingInteger}, + } + expected, ok := contracts[scene] + if !ok { + return fmt.Errorf("无效的 --setting-scene: %s", scene) + } + setting, present := value[expected.field] + if !present || setting == nil { + return responsecheck.Error(operation, "setting_scene_mismatch", "响应缺少请求场景对应的非空设置字段") + } + if !expected.valid(setting) { + return responsecheck.Error(operation, "malformed_setting", "响应中请求场景的设置字段类型不正确") + } + return nil +} + +func attendanceSettingInteger(value any) bool { + number, ok := value.(float64) + return ok && !math.IsNaN(number) && !math.IsInf(number, 0) && math.Trunc(number) == number +} + // ── global setting ────────────────────────────────────────── // GetGlobalSetting 查询全局规则设置(query_global_setting,仅管理员)。 @@ -1502,9 +1816,9 @@ var QueryReportData = shortcut.Shortcut{ }, Description: "根据字段查询考勤报表数据(仅管理员)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, }, Selection: contract.SelectionSpec{ AgentSummary: "根据字段查询考勤报表数据(仅管理员)", @@ -1541,7 +1855,7 @@ var QueryReportData = shortcut.Shortcut{ } columnIds = append(columnIds, id) } - return rt.CallMCP("get_report_columns_value", map[string]any{ + return attendanceCallValue(rt, serverWukong, "get_report_columns_value", map[string]any{ "McpQueryParam": map[string]any{ "targetUserIds": rt.StrSlice("users"), "columnIds": columnIds, @@ -1624,9 +1938,11 @@ var ListLeaveTypes = shortcut.Shortcut{ }, Tips: []string{`dws attendance +list-leave-types`}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("get_leave_types", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_types", map[string]any{ "McpLeaveTypeRequest": map[string]any{}, - }) + }, "leaveTypes", true, nil, func(items []map[string]any) error { + return attendanceValidateExpectedStrings(items, serverWukong+"/get_leave_types", "leaveCode", "") + }, "result") }, } @@ -1695,6 +2011,27 @@ var GetLeaveRecords = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "查询开始日期 YYYY-MM-DD", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "查询结束日期 YYYY-MM-DD", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"user"}, Description: "--user 去空白后必须非空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 YYYY-MM-DD 且 end 不早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Str("user") == "" { + return fmt.Errorf("--user 不能为空") + } + start, err := dayMillis(rt.Str("start")) + if err != nil { + return err + } + end, err := dayMillis(rt.Str("end")) + if err != nil { + return err + } + if end < start { + return fmt.Errorf("--end 不能早于 --start") + } + return nil + }, Tips: []string{`dws attendance +get-leave-records --user USER_ID --leave-code a1b2c3d4-e5f6-7890-abcd-ef1234567890 --start 2026-04-01 --end 2026-04-22`}, Execute: func(rt *shortcut.RuntimeContext) error { start, err := dayMillis(rt.Str("start")) @@ -1713,9 +2050,9 @@ var GetLeaveRecords = shortcut.Shortcut{ if v := rt.Str("leave-code"); v != "" { req["leaveCode"] = v } - return rt.CallMCP("get_leave_balance_records_v2", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_leave_balance_records_v2", map[string]any{ "McpLeaveRecordRequest": req, - }) + }, "records", true, nil, nil, "result") }, } @@ -1879,6 +2216,33 @@ var GetCheckinRecord = shortcut.Shortcut{ {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 yyyy-MM-dd HH:mm:ss", Required: true}, {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 yyyy-MM-dd HH:mm:ss,跨度最多 7 天", Required: true}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"staff-ids"}, Description: "--staff-ids 去空白后必须为 1..100 个且不能重复"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 yyyy-MM-dd HH:mm:ss,end 不早于 start,且跨度不超过 7 天"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := attendanceValidateUserIDs(rt.StrSlice("staff-ids"), 100); err != nil { + return err + } + if rt.Str("operator-corp-id") == "" || rt.Str("operator-staff-id") == "" { + return fmt.Errorf("--operator-corp-id 与 --operator-staff-id 不能为空") + } + start, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("start"), time.Local) + if err != nil { + return fmt.Errorf("--start 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + end, err := time.ParseInLocation("2006-01-02 15:04:05", rt.Str("end"), time.Local) + if err != nil { + return fmt.Errorf("--end 格式错误,应为 yyyy-MM-dd HH:mm:ss: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.Sub(start) > 7*24*time.Hour { + return fmt.Errorf("--start 到 --end 的跨度不能超过 7 天") + } + return nil + }, Tips: []string{`dws attendance +get-checkin-record --operator-corp-id dingXXX --operator-staff-id op001 --staff-ids user001,user002 --start "2026-04-01 00:00:00" --end "2026-04-07 00:00:00"`}, Execute: func(rt *shortcut.RuntimeContext) error { startStr := rt.Str("start") @@ -1894,7 +2258,7 @@ var GetCheckinRecord = shortcut.Shortcut{ if endT.Before(startT) { return fmt.Errorf("--end 不能早于 --start") } - return rt.CallMCP("get_checkin_record", map[string]any{ + return attendanceCallCollection(rt, serverWukong, "get_checkin_record", map[string]any{ "QueryMcpUserRecordRequest": map[string]any{ "operatorCorpId": rt.Str("operator-corp-id"), "operatorStaffId": rt.Str("operator-staff-id"), @@ -1902,7 +2266,7 @@ var GetCheckinRecord = shortcut.Shortcut{ "startTime": startStr, "endTime": endStr, }, - }) + }, "records", true, nil, nil, "result.list") }, } @@ -1954,6 +2318,7 @@ var BossCheck = shortcut.Shortcut{ } func init() { + hardenPublicAttendanceContracts() shortcut.Register( CheckResult, CheckRecord, diff --git a/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go new file mode 100644 index 00000000..53d19d38 --- /dev/null +++ b/internal/shortcut/attendance/attendance_release_cross_platform_coverage_test.go @@ -0,0 +1,1176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +func attendanceRuntimeForTest(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +type attendanceCoverageCaller struct { + text string + err error + calls int + tools []string + arguments []map[string]any +} + +func (caller *attendanceCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.calls++ + caller.tools = append(caller.tools, tool) + caller.arguments = append(caller.arguments, arguments) + if caller.err != nil { + return nil, caller.err + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: caller.text}}}, nil +} + +func (*attendanceCoverageCaller) Format() string { return "json" } +func (*attendanceCoverageCaller) DryRun() bool { return false } +func (*attendanceCoverageCaller) Fields() string { return "" } +func (*attendanceCoverageCaller) JQ() string { return "" } + +func executeAttendanceResponse(t *testing.T, declaration shortcut.Shortcut, values map[string]string, response string) (*attendanceCoverageCaller, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: response} + helpers.InitDepsForTest(t, caller) + err := declaration.Execute(attendanceRuntimeForTest(t, declaration, values)) + return caller, err +} + +func TestCrossPlatformCoverageAttendanceRequestBindingMatrices(t *testing.T) { + userCases := []struct { + name string + items []map[string]any + valid bool + }{ + {"valid", []map[string]any{{"userId": "u1", "workDate": float64(1000)}}, true}, + {"missing user", []map[string]any{{"workDate": float64(1000)}}, false}, + {"wrong user type", []map[string]any{{"userId": 1, "workDate": float64(1000)}}, false}, + {"blank user", []map[string]any{{"userId": " ", "workDate": float64(1000)}}, false}, + {"wrong user", []map[string]any{{"userId": "u2", "workDate": float64(1000)}}, false}, + {"missing time", []map[string]any{{"userId": "u1"}}, false}, + {"wrong time type", []map[string]any{{"userId": "u1", "workDate": "1000"}}, false}, + {"before range", []map[string]any{{"userId": "u1", "workDate": float64(899)}}, false}, + {"after range", []map[string]any{{"userId": "u1", "workDate": float64(1101)}}, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserAndTimeBinding(tc.items, "attendance/test", []string{"u1"}, "userId", "workDate", 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("user/time %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + + approvalCases := []struct { + name string + item map[string]any + valid bool + }{ + {"valid", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, true}, + {"missing user", map[string]any{"bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong user", map[string]any{"userId": "u2", "bizType": float64(3), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"missing type", map[string]any{"userId": "u1", "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"wrong type", map[string]any{"userId": "u1", "bizType": float64(4), "beginTime": float64(900), "endTime": float64(1100)}, false}, + {"invalid range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1001), "endTime": float64(999)}, false}, + {"before range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(700), "endTime": float64(899)}, false}, + {"crosses start", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(800), "endTime": float64(900)}, true}, + {"crosses end", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1100), "endTime": float64(1200)}, true}, + {"after range", map[string]any{"userId": "u1", "bizType": float64(3), "beginTime": float64(1101), "endTime": float64(1200)}, false}, + } + for _, tc := range approvalCases { + err := attendanceValidateApprovalBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, map[int]struct{}{3: {}}, 900, 1100) + if (err == nil) != tc.valid { + t.Errorf("approval %s: err=%v valid=%t", tc.name, err, tc.valid) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}}, "attendance/test", "approveType", "OVERTIME"); err == nil { + t.Fatal("expected string mismatch was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordActualTimeBinding(t *testing.T) { + const startMillis, endMillis = int64(1000), int64(2000) + items := []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}} + if err := attendanceValidateUserAndTimeBinding(items, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis); err != nil { + t.Fatalf("cross-midnight work date with in-range actual check was rejected: %v", err) + } + + invalid := []struct { + name string + item map[string]any + reason string + }{ + {"before range", map[string]any{"userId": "u1", "userCheckTime": startMillis - 1}, "request_range_mismatch"}, + {"after range", map[string]any{"userId": "u1", "userCheckTime": endMillis + 1}, "request_range_mismatch"}, + {"missing actual time", map[string]any{"userId": "u1", "workDate": startMillis}, "missing_request_binding"}, + {"null actual time", map[string]any{"userId": "u1", "userCheckTime": nil}, "malformed_request_binding"}, + {"wrong actual time type", map[string]any{"userId": "u1", "userCheckTime": "1000"}, "malformed_request_binding"}, + {"zero actual time", map[string]any{"userId": "u1", "userCheckTime": 0}, "malformed_request_binding"}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + err := attendanceValidateUserAndTimeBinding([]map[string]any{tc.item}, "attendance/test", []string{"u1"}, "userId", "userCheckTime", startMillis, endMillis) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || !ok || typed.Reason != tc.reason { + t.Fatalf("err=%v reason=%q want=%q", err, reason, tc.reason) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceCheckRecordExecuteBindsActualCheckTime(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + startMillis, _ := dayMillis(values["start"]) + execute := func(t *testing.T, records []map[string]any) (map[string]any, error) { + t.Helper() + caller := &attendanceCoverageCaller{text: attendanceResponseJSON(t, map[string]any{"success": true, "result": records})} + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(CheckRecord)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + err := CheckRecord.Execute(shortcut.RuntimeContextForTest(cmd, CheckRecord)) + if err != nil { + return nil, err + } + if caller.calls != 1 { + t.Fatalf("calls=%d", caller.calls) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%t err=%v", code, emitted, err) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope, nil + } + + t.Run("keeps cross-midnight work date when actual check is in range", func(t *testing.T) { + envelope, err := execute(t, []map[string]any{ + {"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis + 1000}, + {"id": 2, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis}, + }) + if err != nil { + t.Fatal(err) + } + data, ok := envelope["data"].(map[string]any) + records, recordsOK := data["records"].([]any) + if !ok || !recordsOK || data["count"] != float64(2) || len(records) != 2 || records[0].(map[string]any)["id"] != float64(1) || records[1].(map[string]any)["id"] != float64(2) { + t.Fatalf("data=%#v", envelope["data"]) + } + }) + + t.Run("actual-time overfetch fails closed", func(t *testing.T) { + if _, err := execute(t, []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis, "userCheckTime": startMillis - 1}}); err == nil { + t.Fatal("out-of-range actual check was silently filtered") + } + }) + + invalid := []struct { + name string + records []map[string]any + }{ + {"missing identity outside range", []map[string]any{{"userId": "u1", "userCheckTime": startMillis - 1}}}, + {"zero identity outside range", []map[string]any{{"id": 0, "userId": "u1", "userCheckTime": startMillis - 1}}}, + {"wrong user outside range", []map[string]any{{"id": 1, "userId": "other", "userCheckTime": startMillis - 1}}}, + {"missing actual time", []map[string]any{{"id": 1, "userId": "u1", "workDate": startMillis - 1}}}, + {"malformed actual time outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": "bad"}}}, + {"duplicate identities outside range", []map[string]any{{"id": 1, "userId": "u1", "userCheckTime": startMillis - 2}, {"id": 1, "userId": "u1", "userCheckTime": startMillis - 1}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if _, err := execute(t, tc.records); err == nil { + t.Fatal("invalid raw overfetch was hidden") + } + }) + } + + t.Run("downstream failure", func(t *testing.T) { + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckRecord.Execute(attendanceRuntimeForTest(t, CheckRecord, values)); err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("missing collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, CheckRecord, values, `{"success":true,"result":null}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceDetailExecuteFailsClosed(t *testing.T) { + detailCases := []struct { + name string + declaration shortcut.Shortcut + flags map[string]string + valid string + invalid []string + }{ + { + name: "class", + declaration: GetClass, + flags: map[string]string{"class-id": "7"}, + valid: `{"success":true,"result":{"shiftVO":{"id":7}}}`, + invalid: []string{ + `{"result":{"shiftVO":{"id":7}}}`, + `{"success":false,"result":{"shiftVO":{"id":7}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"shiftVO":{"id":8}}}`, + }, + }, + { + name: "overtime", + declaration: GetOvertimeRule, + flags: map[string]string{"overtime-id": "9"}, + valid: `{"success":true,"result":[{"id":9}]}`, + invalid: []string{ + `{"result":[{"id":9}]}`, + `{"success":false,"result":[{"id":9}]}`, + `{"success":true,"result":null}`, + `{"success":true,"result":["bad"]}`, + `{"success":true,"result":[{"id":10}]}`, + }, + }, + } + for _, detail := range detailCases { + t.Run(detail.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, detail.valid) + if err != nil || caller.calls != 1 { + t.Fatalf("valid detail err=%v calls=%d", err, caller.calls) + } + }) + for index, response := range detail.invalid { + t.Run(fmt.Sprintf("%s invalid %d", detail.name, index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, detail.declaration, detail.flags, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid detail err=%v calls=%d", err, caller.calls) + } + }) + } + } +} + +func TestCrossPlatformCoverageAttendanceSelfSettingExecuteBinding(t *testing.T) { + invalid := []string{ + `{"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":false,"result":{"userId":"user","checkRemindSetting":{}}}`, + `{"success":true,"result":null}`, + `{"success":true,"result":"bad"}`, + `{"success":true,"result":{"userId":"other","checkRemindSetting":{}}}`, + `{"success":true,"result":{"userId":"user"}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":null}}`, + `{"success":true,"result":{"userId":"user","checkRemindSetting":true}}`, + } + for index, response := range invalid { + t.Run(fmt.Sprintf("invalid %d", index), func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": "user", "setting-scene": "checkRemind"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("invalid self setting err=%v calls=%d", err, caller.calls) + } + }) + } + t.Run("normalizes user once", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSelfSetting, map[string]string{"user": " user ", "setting-scene": "checkRemind"}, `{"success":true,"result":{"userId":"user","checkRemindSetting":{}}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("normalized self setting err=%v calls=%d", err, caller.calls) + } + request, ok := caller.arguments[0]["RuleMcpQuerySelfSettingRequest"].(map[string]any) + if !ok || request["userId"] != "user" { + t.Fatalf("normalized request = %#v", caller.arguments[0]) + } + }) + + validTypes := []struct { + scene string + field string + value any + }{ + {"checkRemind", "checkRemindSetting", map[string]any{}}, + {"fastCheck", "fastCheckLateNeedConfirm", false}, + {"checkResultNotify", "checkResultMsg", float64(0)}, + {"lackRemind", "lackRemindUser", float64(1)}, + {"personalAttendStatNotify", "personDailyReportSwitch", float64(1)}, + {"bossAttendStatNotify", "bossMonthReportType", float64(3)}, + } + for _, item := range validTypes { + value := map[string]any{"userId": "user", item.field: item.value} + if err := attendanceValidateSelfSetting(value, "user", item.scene); err != nil { + t.Errorf("scene %s rejected observed type: %v", item.scene, err) + } + } + +} + +func TestCrossPlatformCoverageAttendanceParamMappingsAreExplicit(t *testing.T) { + expected := map[*shortcut.Shortcut]map[string]string{ + &CheckResult: {"users": "users", "start": "start", "end": "end", "offset": "offset", "limit": "limit"}, + &CheckRecord: {"users": "users", "start": "start", "end": "end"}, + &ListApprove: {"users": "users", "types": "types", "start": "start", "end": "end"}, + &GetApproveTemplate: {"type": "type"}, + &GetSchedule: {"users": "users", "start": "start", "end": "end"}, + &SearchClass: {"query": "query", "filter-type": "filterType", "page": "page", "limit": "limit"}, + &GetClass: {"class-id": "classId"}, + &SearchAdjustmentRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetOvertimeRule: {"overtime-id": "overtimeId"}, + &SearchOvertimeRule: {"query": "query", "page": "page", "limit": "limit"}, + &GetSelfSetting: {"setting-scene": "settingScene", "user": "user"}, + } + for declaration, want := range expected { + got := make(map[string]string, len(declaration.Contract.Parameters)) + for _, parameter := range declaration.Contract.Parameters { + got[parameter.Name] = parameter.Property + } + if len(got) != len(want) { + t.Errorf("%s parameter count=%d want=%d: %#v", declaration.Command, len(got), len(want), got) + } + for name, property := range want { + if got[name] != property { + t.Errorf("%s --%s property=%q want=%q", declaration.Command, name, got[name], property) + } + } + } +} + +func TestCrossPlatformCoverageAttendanceRuntimeConstraintsFailBeforeCall(t *testing.T) { + manyUsers := make([]string, 101) + for index := range manyUsers { + manyUsers[index] = fmt.Sprintf("u%d", index) + } + cases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {"check result too many users", CheckResult, map[string]string{"users": strings.Join(manyUsers, ","), "start": "2026-01-01", "end": "2026-01-31"}}, + {"check result reversed dates", CheckResult, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"check result over month", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-02-02"}}, + {"check result bad limit", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1001"}}, + {"check result bad offset", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}}, + {"check record duplicate users", CheckRecord, map[string]string{"users": "u1,u1", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "types": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"approve reversed dates", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-02-01", "end": "2026-01-31"}}, + {"schedule empty users", GetSchedule, map[string]string{"users": ",", "start": "2026-01-01", "end": "2026-01-31"}}, + {"schedule reversed dates", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"search class page zero", SearchClass, map[string]string{"page": "0"}}, + {"search adjustment limit too large", SearchAdjustmentRule, map[string]string{"limit": "201"}}, + {"search overtime limit zero", SearchOvertimeRule, map[string]string{"limit": "0"}}, + {"class id zero", GetClass, map[string]string{"class-id": "0"}}, + {"overtime id zero", GetOvertimeRule, map[string]string{"overtime-id": "0"}}, + {"leave records reversed dates", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-02-01", "end": "2026-01-31"}}, + {"checkin duplicate users", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1,u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}}, + {"checkin over seven days", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "operator", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-08 00:00:01"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: `{"success":true,"result":[]}`} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no Validate", tc.declaration.Command) + } + if err := tc.declaration.Validate(rt); err == nil { + t.Fatalf("invalid values accepted for %s", tc.declaration.Command) + } + if caller.calls != 0 { + t.Fatalf("%s made %d downstream calls before validation", tc.declaration.Command, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendancePaginationLivesInEnvelopeMeta(t *testing.T) { + emit := func(complete bool, nextToken string) map[string]any { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(CheckResult)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, CheckResult) + items := []map[string]any{{"id": int64(1), "userId": "redacted", "workDate": int64(1)}} + if err := attendanceOutputCollection(rt, "records", items, complete, map[string]any{"limit": 1, "nextOffset": 1}, true, nextToken); err != nil { + t.Fatal(err) + } + if _, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted { + t.Fatalf("emit err=%v emitted=%t", err, emitted) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode envelope: %v", err) + } + return envelope + } + + envelope := emit(false, "1") + data, ok := envelope["data"].(map[string]any) + if !ok || data["count"] != float64(1) { + t.Fatalf("business data=%#v", envelope["data"]) + } + for _, forbidden := range []string{"complete", "limit", "nextOffset", "nextPage", "totalCount", "totalPage"} { + if _, present := data[forbidden]; present { + t.Errorf("pagination field %q leaked into business data", forbidden) + } + } + meta, ok := envelope["meta"].(map[string]any) + if !ok { + t.Fatalf("meta=%#v", envelope["meta"]) + } + pagination, ok := meta["pagination"].(map[string]any) + if !ok || pagination["endpoint_exhausted"] != false || pagination["next_token"] != "1" { + t.Fatalf("pagination=%#v", meta["pagination"]) + } + exhaustedEnvelope := emit(true, "") + exhaustedMeta := exhaustedEnvelope["meta"].(map[string]any) + exhaustedPagination := exhaustedMeta["pagination"].(map[string]any) + if exhaustedPagination["endpoint_exhausted"] != true { + t.Fatalf("exhausted pagination=%#v", exhaustedPagination) + } + if _, present := exhaustedPagination["next_token"]; present { + t.Fatalf("exhausted pagination published next_token: %#v", exhaustedPagination) + } +} + +func TestCrossPlatformCoverageAttendanceCommonCallersFailClosed(t *testing.T) { + tests := []struct { + name string + run func(*shortcut.RuntimeContext) error + text string + err error + want bool + }{ + { + name: "collection downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "collection malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, nil, "result") + }, + text: `{"success":true,"result":null}`, + want: false, + }, + { + name: "collection validator", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, nil, func([]map[string]any) error { + return fmt.Errorf("binding mismatch") + }, "result") + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "collection success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallCollection(rt, "attendance-test", "collection", nil, "items", true, map[string]any{"source": "test"}, nil, "result") + }, + text: `{"success":true,"result":[]}`, + want: true, + }, + { + name: "value downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "value malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true}`, + want: false, + }, + { + name: "value success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallValue(rt, "attendance-test", "value", nil) + }, + text: `{"success":true,"result":false}`, + want: true, + }, + { + name: "object downstream error", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + err: fmt.Errorf("downstream unavailable"), + want: false, + }, + { + name: "object malformed", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":[]}`, + want: false, + }, + { + name: "object success", + run: func(rt *shortcut.RuntimeContext) error { + return attendanceCallObject(rt, "attendance-test", "object", nil) + }, + text: `{"success":true,"result":{"id":1}}`, + want: true, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &attendanceCoverageCaller{text: tc.text, err: tc.err} + helpers.InitDepsForTest(t, caller) + rt := attendanceRuntimeForTest(t, CheckResult, nil) + err := tc.run(rt) + if (err == nil) != tc.want || caller.calls != 1 { + t.Fatalf("err=%v calls=%d wantSuccess=%t", err, caller.calls, tc.want) + } + }) + } + + t.Run("legacy output", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, ListLeaveTypes, nil) + if err := attendanceOutputCollection(rt, "items", []map[string]any{{"leaveCode": "code"}}, true, map[string]any{"source": "legacy"}, false, ""); err != nil { + t.Fatal(err) + } + }) + + t.Run("invalid continuation", func(t *testing.T) { + rt := attendanceRuntimeForTest(t, CheckResult, nil) + if err := attendanceOutputCollection(rt, "records", nil, false, nil, true, ""); err == nil { + t.Fatal("non-terminal pagination without next token was accepted") + } + }) +} + +func TestCrossPlatformCoverageAttendanceIdentityAndInputHelpers(t *testing.T) { + if value, ok := attendanceNestedValue(map[string]any{"outer": map[string]any{"id": int32(7)}}, "outer", "id"); !ok || value != int32(7) { + t.Fatalf("nested identity=%#v ok=%t", value, ok) + } + for _, path := range [][]string{{"missing"}, {"outer", "id"}} { + item := map[string]any{"outer": "not-an-object"} + if _, ok := attendanceNestedValue(item, path...); ok { + t.Fatalf("invalid nested path accepted: %#v", path) + } + } + + positive := []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} + for _, value := range positive { + if _, ok := attendancePositiveInteger(value); !ok { + t.Errorf("positive identity rejected: %#v", value) + } + } + invalid := []any{int(0), int32(-1), int64(0), 1.5, json.Number("bad"), "1", nil} + for _, value := range invalid { + if _, ok := attendancePositiveInteger(value); ok { + t.Errorf("invalid identity accepted: %#v", value) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"nested": map[string]any{"id": int32(1)}}}, "attendance/test", "nested", "id"); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"id": 0}}, + {{"id": 1}, {"id": 1}}, + } { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid identity collection accepted: %#v", items) + } + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"code": " A "}, {"code": "B"}}, "attendance/test", "code", ""); err != nil { + t.Fatal(err) + } + for _, items := range [][]map[string]any{ + {{}}, + {{"code": 1}}, + {{"code": " "}}, + {{"code": "A"}, {"code": "A"}}, + } { + if err := attendanceValidateExpectedStrings(items, "attendance/test", "code", ""); err == nil { + t.Fatalf("invalid string identity collection accepted: %#v", items) + } + } + + userCases := []struct { + values []string + maximum int + valid bool + }{ + {[]string{"u1"}, 1, true}, + {nil, 1, false}, + {[]string{"u1", "u2"}, 1, false}, + {[]string{""}, 1, false}, + {[]string{" u1"}, 1, false}, + {[]string{"u1", "u1"}, 2, false}, + } + for _, tc := range userCases { + err := attendanceValidateUserIDs(tc.values, tc.maximum) + if (err == nil) != tc.valid { + t.Errorf("users=%#v maximum=%d err=%v", tc.values, tc.maximum, err) + } + } + + for _, tc := range []struct { + start string + end string + valid bool + }{ + {"2026-01-01", "2026-02-01", true}, + {"bad", "2026-01-01", false}, + {"2026-01-01", "bad", false}, + {"2026-02-01", "2026-01-01", false}, + {"2026-01-01", "2026-02-02", false}, + } { + err := attendanceValidateMonthRange(tc.start, tc.end) + if (err == nil) != tc.valid { + t.Errorf("month range %q..%q err=%v", tc.start, tc.end, err) + } + } +} + +func TestCrossPlatformCoverageAttendancePaginationEvidenceBoundaries(t *testing.T) { + tests := []struct { + name string + data map[string]any + page int + limit int + itemCount int + complete bool + valid bool + }{ + {"invalid request", map[string]any{"success": true, "result": map[string]any{}}, 0, 10, 0, false, false}, + {"malformed result", map[string]any{"success": true, "result": []any{}}, 1, 10, 0, false, false}, + {"current page type", map[string]any{"success": true, "result": map[string]any{"currentPage": "1", "totalPage": 1}}, 1, 10, 0, false, false}, + {"current page mismatch", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 1}}, 1, 10, 0, false, false}, + {"total page type", map[string]any{"success": true, "result": map[string]any{"totalPage": "1"}}, 1, 10, 0, false, false}, + {"negative total page", map[string]any{"success": true, "result": map[string]any{"totalPage": -1}}, 1, 10, 0, false, false}, + {"zero pages invalid current page", map[string]any{"success": true, "result": map[string]any{"totalPage": 0}}, 2, 10, 0, false, false}, + {"page out of range", map[string]any{"success": true, "result": map[string]any{"totalPage": 1}}, 2, 10, 0, false, false}, + {"total count type", map[string]any{"success": true, "result": map[string]any{"totalCount": "1"}}, 1, 10, 0, false, false}, + {"negative total count", map[string]any{"success": true, "result": map[string]any{"totalCount": -1}}, 1, 10, 0, false, false}, + {"empty with nonzero total", map[string]any{"success": true, "result": map[string]any{"totalCount": 1}}, 1, 10, 0, false, false}, + {"count before page", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 2, 10, 0, false, false}, + {"items exceed total", map[string]any{"success": true, "result": map[string]any{"totalCount": 5}}, 1, 10, 6, false, false}, + {"missing evidence", map[string]any{"success": true, "result": map[string]any{}}, 1, 10, 0, false, false}, + {"conflicting evidence", map[string]any{"success": true, "result": map[string]any{"totalPage": 2, "totalCount": 5}}, 1, 10, 5, false, false}, + {"total mismatch", map[string]any{"success": true, "result": map[string]any{"totalPage": 3, "totalCount": 15}}, 1, 10, 10, false, false}, + {"no progress", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 0, false, false}, + {"short page", map[string]any{"success": true, "result": map[string]any{"totalPage": 2}}, 1, 10, 5, false, false}, + {"terminal count mismatch", map[string]any{"success": true, "result": map[string]any{"totalCount": 15}}, 2, 10, 4, false, false}, + {"empty terminal", map[string]any{"success": true, "result": map[string]any{"totalPage": 0, "totalCount": 0}}, 1, 10, 0, true, true}, + {"full nonterminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 1, "totalPage": 2, "totalCount": 20}}, 1, 10, 10, false, true}, + {"short terminal", map[string]any{"success": true, "result": map[string]any{"currentPage": 2, "totalPage": 2, "totalCount": 15}}, 2, 10, 5, true, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + complete, extra, err := attendancePageEvidence(tc.data, "attendance/test", tc.page, tc.limit, tc.itemCount) + if (err == nil) != tc.valid { + t.Fatalf("err=%v extra=%#v", err, extra) + } + if tc.valid && complete != tc.complete { + t.Fatalf("complete=%t want=%t extra=%#v", complete, tc.complete, extra) + } + if tc.valid && !tc.complete && extra["nextPage"] != tc.page+1 { + t.Fatalf("nonterminal extra=%#v", extra) + } + }) + } + + for _, tc := range []struct { + page string + limit string + valid bool + }{ + {"1", "200", true}, + {"0", "20", false}, + {"1", "0", false}, + {"1", "201", false}, + } { + rt := attendanceRuntimeForTest(t, SearchClass, map[string]string{"page": tc.page, "limit": tc.limit}) + page, limit, err := attendancePageInput(rt) + if (err == nil) != tc.valid { + t.Errorf("page=%s limit=%s got=(%d,%d) err=%v", tc.page, tc.limit, page, limit, err) + } + if err2 := attendanceValidatePageRequest(rt); (err2 == nil) != tc.valid { + t.Errorf("validator page=%s limit=%s err=%v", tc.page, tc.limit, err2) + } + } + + for _, tc := range []struct { + value any + want int + valid bool + }{ + {int(1), 1, true}, {int32(2), 2, true}, {int64(3), 3, true}, {float64(4), 4, true}, + {float64(1.5), 0, false}, {"1", 0, false}, + } { + got, ok := attendanceInt(tc.value) + if got != tc.want || ok != tc.valid { + t.Errorf("attendanceInt(%#v)=(%d,%t), want (%d,%t)", tc.value, got, ok, tc.want, tc.valid) + } + } +} + +func attendanceResponseJSON(t *testing.T, value any) string { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +func TestCrossPlatformCoverageAttendanceChangedValidators(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid bool + }{ + {"check result valid", CheckResult, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "0"}, true}, + {"check record valid", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"approve empty users", ListApprove, map[string]string{"types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve empty types", ListApprove, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid start", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "bad", "end": "2026-01-31"}, false}, + {"approve invalid end", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "bad"}, false}, + {"approve duplicate mapped type", ListApprove, map[string]string{"users": "u1", "types": "leave,请假", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve invalid type", ListApprove, map[string]string{"users": "u1", "types": "invalid", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"approve valid", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"schedule invalid start", GetSchedule, map[string]string{"users": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"schedule invalid end", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"schedule reverse", GetSchedule, map[string]string{"users": "u1", "start": "2026-02-01", "end": "2026-01-31"}, false}, + {"schedule valid", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"class valid", GetClass, map[string]string{"class-id": "1"}, true}, + {"overtime valid", GetOvertimeRule, map[string]string{"overtime-id": "1"}, true}, + {"self setting empty user", GetSelfSetting, map[string]string{"user": "", "setting-scene": "checkRemind"}, false}, + {"self setting valid", GetSelfSetting, map[string]string{"user": "u1", "setting-scene": "checkRemind"}, true}, + {"leave records empty user", GetLeaveRecords, map[string]string{"user": "", "start": "2026-01-01", "end": "2026-01-31"}, false}, + {"leave records invalid start", GetLeaveRecords, map[string]string{"user": "u1", "start": "bad", "end": "2026-01-31"}, false}, + {"leave records invalid end", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "bad"}, false}, + {"leave records valid", GetLeaveRecords, map[string]string{"user": "u1", "start": "2026-01-01", "end": "2026-01-31"}, true}, + {"checkin missing operator", GetCheckinRecord, map[string]string{"operator-corp-id": "", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid start", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "bad", "end": "2026-01-02 00:00:00"}, false}, + {"checkin invalid end", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "bad"}, false}, + {"checkin reverse", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-02 00:00:00", "end": "2026-01-01 00:00:00"}, false}, + {"checkin valid", GetCheckinRecord, map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + rt := attendanceRuntimeForTest(t, tc.declaration, tc.values) + if tc.declaration.Validate == nil { + t.Fatalf("%s has no validator", tc.declaration.Command) + } + err := tc.declaration.Validate(rt) + if (err == nil) != tc.valid { + t.Fatalf("err=%v wantValid=%t", err, tc.valid) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceExactRecordExecutors(t *testing.T) { + startMillis, _ := dayMillis("2026-01-01") + endStartMillis, _ := dayMillis("2026-01-31") + endMillis, _ := dateToMillis("2026-01-31", true) + nextDayStartMillis, _ := dayMillis("2026-02-01") + finalDayNoon := endStartMillis + 12*60*60*1000 + validRecord := map[string]any{"id": 1, "userId": "u1", "workDate": startMillis} + if endMillis != nextDayStartMillis-1 { + t.Fatalf("end-of-day=%d want=%d", endMillis, nextDayStartMillis-1) + } + explicitEnd, err := dateToMillis("2026-01-31 12:34:56", true) + explicitWant, _ := flexMillis("2026-01-31 12:34:56") + if err != nil || explicitEnd != explicitWant { + t.Fatalf("explicit datetime end=%d want=%d err=%v", explicitEnd, explicitWant, err) + } + + t.Run("check result bad execute bounds", func(t *testing.T) { + for _, values := range []map[string]string{ + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "0"}, + {"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "offset": "-1"}, + } { + caller, err := executeAttendanceResponse(t, CheckResult, values, `{"success":true,"result":[]}`) + if err == nil || caller.calls != 0 { + t.Fatalf("values=%#v err=%v calls=%d", values, err, caller.calls) + } + } + }) + + t.Run("check result downstream and response failures", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + responses := []string{ + `{"success":true,"result":null}`, + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 0, "userId": "u1", "workDate": startMillis}}}), + attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}}), + } + for _, response := range responses { + caller, err := executeAttendanceResponse(t, CheckResult, values, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + caller := &attendanceCoverageCaller{err: fmt.Errorf("backend unavailable")} + helpers.InitDepsForTest(t, caller) + if err := CheckResult.Execute(attendanceRuntimeForTest(t, CheckResult, values)); err == nil || caller.calls != 1 { + t.Fatalf("downstream err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("check result short and full pages", func(t *testing.T) { + shortValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2", "offset": "0"} + caller, err := executeAttendanceResponse(t, CheckResult, shortValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("short err=%v calls=%d", err, caller.calls) + } + fullValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "1", "offset": "4"} + caller, err = executeAttendanceResponse(t, CheckResult, fullValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{validRecord}})) + if err != nil || caller.calls != 1 { + t.Fatalf("full err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("end date includes the full business day", func(t *testing.T) { + checkValues := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31", "limit": "2"} + checkRow := map[string]any{"id": 1, "userId": "u1", "workDate": finalDayNoon} + caller, err := executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final-day midday row: err=%v calls=%d", err, caller.calls) + } + + approveValues := map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"} + approval := map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": finalDayNoon, "endTime": finalDayNoon + 60*60*1000} + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + if err != nil || caller.calls != 1 { + t.Fatalf("approval rejected final-day midday interval: err=%v calls=%d", err, caller.calls) + } + + checkRow["workDate"] = endMillis + caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})) + if err != nil || caller.calls != 1 { + t.Fatalf("check result rejected final millisecond: err=%v calls=%d", err, caller.calls) + } + checkRow["workDate"] = nextDayStartMillis + if caller, err = executeAttendanceResponse(t, CheckResult, checkValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{checkRow}})); err == nil || caller.calls != 1 { + t.Fatalf("check result accepted next-day row: err=%v calls=%d", err, caller.calls) + } + + approval["beginTime"] = nextDayStartMillis + approval["endTime"] = nextDayStartMillis + 1000 + caller, err = executeAttendanceResponse(t, ListApprove, approveValues, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{approval}})) + typed, ok := err.(*apperrors.Error) + reason := "" + if ok { + reason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || typed.Reason != "request_range_mismatch" { + t.Fatalf("approval next-day result err=%v calls=%d reason=%q", err, caller.calls, reason) + } + }) + + collectionCases := []struct { + name string + declaration shortcut.Shortcut + values map[string]string + valid map[string]any + invalid map[string]any + }{ + {"check record", CheckRecord, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "workDate": startMillis - 24*60*60*1000, "userCheckTime": startMillis}, map[string]any{"id": 1, "userId": "other", "userCheckTime": startMillis}}, + {"approve", ListApprove, map[string]string{"users": "u1", "types": "leave", "start": "2026-01-01", "end": "2026-01-31"}, map[string]any{"id": 1, "userId": "u1", "bizType": 3, "beginTime": startMillis, "endTime": endMillis}, map[string]any{"id": 1, "userId": "u1", "bizType": 4, "beginTime": startMillis, "endTime": endMillis}}, + {"schedule", GetSchedule, map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"}, validRecord, map[string]any{"id": 1, "userId": "other", "workDate": startMillis}}, + } + t.Run("schedule null responses fail closed", func(t *testing.T) { + values := map[string]string{"users": "u1", "start": "2026-01-01", "end": "2026-01-31"} + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "literal null", response: `null`, reason: "empty_tool_response"}, + {name: "null collection", response: `{"success":true,"result":null}`, reason: "malformed_collection"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSchedule, values, tc.response) + typed, ok := err.(*apperrors.Error) + actualReason := "" + if ok { + actualReason = typed.Reason + } + if err == nil || caller.calls != 1 || !ok || actualReason != tc.reason { + t.Fatalf("err=%v calls=%d reason=%q want=%q", err, caller.calls, actualReason, tc.reason) + } + }) + } + }) + for _, tc := range collectionCases { + t.Run(tc.name+" valid", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.valid}})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid binding", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{tc.invalid}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" invalid identity", func(t *testing.T) { + invalidIdentity := make(map[string]any, len(tc.valid)) + for key, value := range tc.valid { + invalidIdentity[key] = value + } + invalidIdentity["id"] = 0 + caller, err := executeAttendanceResponse(t, tc.declaration, tc.values, attendanceResponseJSON(t, map[string]any{"success": true, "result": []any{invalidIdentity}})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + } +} + +func TestCrossPlatformCoverageAttendanceSearchProjectorsFailClosed(t *testing.T) { + classValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": map[string]any{"id": 1, "name": "n", "ownerName": "o"}}}}} + classes, err := searchClassProject(classValid) + if err != nil || len(classes) != 1 || classes[0]["classId"] != int64(1) || classes[0]["name"] != "n" || classes[0]["ownerName"] != "o" { + t.Fatalf("class projection=%#v err=%v", classes, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"shiftVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"classId": 1}}}}, + } { + if _, err := searchClassProject(data); err == nil { + t.Fatalf("invalid class projection accepted: %#v", data) + } + } + + ruleValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": map[string]any{"id": 2, "name": "rule"}}}}} + rules, err := searchRuleProject(ruleValid, "attendance/test", "result.items") + if err != nil || len(rules) != 1 || rules[0]["ruleId"] != int64(2) || rules[0]["name"] != "rule" { + t.Fatalf("rule projection=%#v err=%v", rules, err) + } + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"items": []any{map[string]any{"entityVO": "bad"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"id": 1}, map[string]any{"ruleId": 1}}}}, + } { + if _, err := searchRuleProject(data, "attendance/test", "result.items"); err == nil { + t.Fatalf("invalid rule projection accepted: %#v", data) + } + } + + groupValid := map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"group_id": "g1", "group_name": "group", "group_type": "FIXED"}}}} + groups, err := searchGroupProject(groupValid) + if err != nil || len(groups) != 1 || groups[0]["groupId"] != "g1" || groups[0]["name"] != "group" || groups[0]["type"] != "FIXED" { + t.Fatalf("group projection=%#v err=%v", groups, err) + } + if _, err := searchGroupProject(map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing id"}}}}); err == nil { + t.Fatal("group without identity was accepted") + } +} + +func TestCrossPlatformCoverageAttendanceSearchExecutorsPagination(t *testing.T) { + type searchCase struct { + name string + declaration shortcut.Shortcut + tool string + resultKey string + item map[string]any + } + tests := []searchCase{ + {"class", SearchClass, "get_class_list", "items", map[string]any{"shiftVO": map[string]any{"id": 1}}}, + {"adjustment", SearchAdjustmentRule, "get_adjustment_rule", "adjustmentList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + {"overtime", SearchOvertimeRule, "get_overtime_rule", "atRuleList", map[string]any{"entityVO": map[string]any{"id": 1}}}, + } + for _, tc := range tests { + t.Run(tc.name+" invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" malformed collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, `{"success":true,"result":{}}`) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run(tc.name+" missing pagination evidence", func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err == nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + for _, page := range []struct { + name string + totalPage int + total int + }{ + {"terminal", 1, 1}, + {"nonterminal", 2, 2}, + } { + t.Run(tc.name+" "+page.name, func(t *testing.T) { + result := map[string]any{tc.resultKey: []any{tc.item}, "currentPage": 1, "totalPage": page.totalPage, "totalCount": page.total} + caller, err := executeAttendanceResponse(t, tc.declaration, map[string]string{"query": "q", "page": "1", "limit": "1"}, attendanceResponseJSON(t, map[string]any{"success": true, "result": result})) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + if caller.tools[0] != tc.tool { + t.Fatalf("tool=%q want=%q", caller.tools[0], tc.tool) + } + }) + } + } + + t.Run("group invalid page", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "0", "limit": "1"}, `{"success":true}`) + if err == nil || caller.calls != 0 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + t.Run("group malformed and pagination failure", func(t *testing.T) { + for _, response := range []string{ + `{"success":true,"result":{}}`, + `{"success":true,"result":{"items":[{"id":"g1"}]}}`, + } { + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"query": "q", "type": "FIXED", "query-position": "true", "query-ble": "false", "page": "1", "limit": "1"}, response) + if err == nil || caller.calls != 1 { + t.Fatalf("response=%s err=%v calls=%d", response, err, caller.calls) + } + } + }) + t.Run("group terminal", func(t *testing.T) { + response := `{"success":true,"result":{"items":[{"id":"g1","name":"group","type":"FIXED"}],"currentPage":1,"totalPage":1,"totalCount":1}}` + caller, err := executeAttendanceResponse(t, SearchGroup, map[string]string{"page": "1", "limit": "1"}, response) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +func TestCrossPlatformCoverageAttendanceRemainingChangedExecutors(t *testing.T) { + t.Run("approve template exact type", func(t *testing.T) { + for _, tc := range []struct { + value string + response string + valid bool + calls int + }{ + {"invalid", `{"success":true,"result":[]}`, false, 0}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"OVERTIME","submitUrl":"https://example.invalid/1"}]}`, false, 1}, + {"leave", `{"success":true,"result":[{"processCode":"p1","approveType":"LEAVE","submitUrl":"https://example.invalid/1"}]}`, true, 1}, + {"TRAVEL", `{"success":true,"result":[{"processCode":"p1","approveType":"TRAVEL","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"TRAVEL","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + {"OUT", `{"success":true,"result":[{"processCode":"p1","approveType":"OUT","submitUrl":"https://example.invalid/1"},{"processCode":"p2","approveType":"OUT","submitUrl":"https://example.invalid/2"}]}`, true, 1}, + } { + caller, err := executeAttendanceResponse(t, GetApproveTemplate, map[string]string{"type": tc.value}, tc.response) + if (err == nil) != tc.valid || caller.calls != tc.calls { + t.Errorf("type=%q err=%v calls=%d wantValid=%t wantCalls=%d", tc.value, err, caller.calls, tc.valid, tc.calls) + } + } + }) + + t.Run("hidden adjustment detail still fails closed", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetAdjustmentRule, map[string]string{"adjustment-id": "7"}, `{"success":true,"result":{"id":7}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("summary object", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, GetSummary, map[string]string{"user": "u1", "date": "2026-01-01", "stats-type": "week"}, `{"success":true,"result":{"days":1}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("self setting helper rejects invalid request and scene", func(t *testing.T) { + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1", "checkRemindSetting": map[string]any{}}, " ", "checkRemind"); err == nil { + t.Fatal("empty requested user accepted") + } + if err := attendanceValidateSelfSetting(map[string]any{"userId": "u1"}, "u1", "not-a-scene"); err == nil { + t.Fatal("invalid setting scene accepted") + } + }) + + t.Run("report value", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, QueryReportData, map[string]string{"users": "u1", "columns": "1, 2", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"}, `{"success":true,"result":false}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave type exact collection", func(t *testing.T) { + caller, err := executeAttendanceResponse(t, ListLeaveTypes, nil, `{"success":true,"result":[{"leaveCode":"code"}]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("leave records optional code", func(t *testing.T) { + values := map[string]string{"user": "u1", "leave-code": "code", "start": "2026-01-01", "end": "2026-01-31"} + caller, err := executeAttendanceResponse(t, GetLeaveRecords, values, `{"success":true,"result":[]}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) + + t.Run("checkin record nested collection", func(t *testing.T) { + values := map[string]string{"operator-corp-id": "corp", "operator-staff-id": "op", "staff-ids": "u1", "start": "2026-01-01 00:00:00", "end": "2026-01-02 00:00:00"} + caller, err := executeAttendanceResponse(t, GetCheckinRecord, values, `{"success":true,"result":{"list":[]}}`) + if err != nil || caller.calls != 1 { + t.Fatalf("err=%v calls=%d", err, caller.calls) + } + }) +} + +var _ edition.ToolCaller = (*attendanceCoverageCaller)(nil) diff --git a/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..900f2ea2 --- /dev/null +++ b/internal/shortcut/attendance/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,267 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoveragePublicAttendanceContractsAreUnifiedAndTyped(t *testing.T) { + shortcuts := []struct { + name string + rollout output.RolloutState + hasResult bool + resultSchema string + identity string + cursor string + }{ + {CheckResult.Command, CheckResult.OutputRollout, CheckResult.Contract.Result != nil, string(CheckResult.Contract.Result.DataSchema), `"id"`, "offset"}, + {CheckRecord.Command, CheckRecord.OutputRollout, CheckRecord.Contract.Result != nil, string(CheckRecord.Contract.Result.DataSchema), `"id"`, ""}, + {ListApprove.Command, ListApprove.OutputRollout, ListApprove.Contract.Result != nil, string(ListApprove.Contract.Result.DataSchema), `"id"`, ""}, + {GetApproveTemplate.Command, GetApproveTemplate.OutputRollout, GetApproveTemplate.Contract.Result != nil, string(GetApproveTemplate.Contract.Result.DataSchema), `"processCode"`, ""}, + {SearchClass.Command, SearchClass.OutputRollout, SearchClass.Contract.Result != nil, string(SearchClass.Contract.Result.DataSchema), `"classId"`, "page"}, + {SearchAdjustmentRule.Command, SearchAdjustmentRule.OutputRollout, SearchAdjustmentRule.Contract.Result != nil, string(SearchAdjustmentRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + {GetOvertimeRule.Command, GetOvertimeRule.OutputRollout, GetOvertimeRule.Contract.Result != nil, string(GetOvertimeRule.Contract.Result.DataSchema), `"id"`, ""}, + {SearchOvertimeRule.Command, SearchOvertimeRule.OutputRollout, SearchOvertimeRule.Contract.Result != nil, string(SearchOvertimeRule.Contract.Result.DataSchema), `"ruleId"`, "page"}, + } + for _, item := range shortcuts { + if item.rollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout = %q, want unified_active", item.name, item.rollout) + } + if !item.hasResult || strings.TrimSpace(item.resultSchema) == "" { + t.Errorf("%s must publish a non-empty Result", item.name) + } + if !json.Valid([]byte(item.resultSchema)) || !strings.Contains(item.resultSchema, item.identity) { + t.Errorf("%s Result must declare stable identity %s: %s", item.name, item.identity, item.resultSchema) + } + if len(attendanceDeclarationByName(item.name).Contract.Result.SensitivePaths) == 0 { + t.Errorf("%s must publish reviewed sensitive_paths", item.name) + } + if strings.Contains(item.resultSchema, `"complete"`) || strings.Contains(item.resultSchema, `"nextPage"`) || strings.Contains(item.resultSchema, `"nextOffset"`) { + t.Errorf("%s Result must not place pagination controls in business data", item.name) + } + if item.cursor == "" { + if itemPagination := attendanceDeclarationByName(item.name).Contract.Pagination; itemPagination != nil { + t.Errorf("%s unexpectedly publishes Pagination", item.name) + } + } else if pagination := attendanceDeclarationByName(item.name).Contract.Pagination; pagination == nil || pagination.CursorParameter != item.cursor { + t.Errorf("%s Pagination = %#v, want cursor_parameter=%s", item.name, pagination, item.cursor) + } + } +} + +func attendanceDeclarationByName(name string) *shortcut.Shortcut { + for _, declaration := range []*shortcut.Shortcut{&CheckResult, &CheckRecord, &ListApprove, &GetApproveTemplate, &SearchClass, &SearchAdjustmentRule, &GetOvertimeRule, &SearchOvertimeRule} { + if declaration.Command == name { + return declaration + } + } + return &shortcut.Shortcut{} +} + +func TestCrossPlatformCoverageAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&GetSummary, &ListLeaveTypes, &GetLeaveRecords, &GetCheckinRecord, &GetSchedule, &GetSelfSetting} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract = rollout %q result=%v pagination=%v", declaration.Command, declaration.OutputRollout, declaration.Contract.Result, declaration.Contract.Pagination) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != attendanceCompatibilityInterfaceReason { + t.Errorf("%s must preserve its Schema-compatible non-public Interface", declaration.Command) + } + } + if GetClass.OutputRollout != output.RolloutLegacyOnly || GetClass.Contract.Result != nil || GetClass.Contract.Pagination != nil || GetClass.Contract.Interface == nil || GetClass.Contract.Interface.Availability != contract.InterfaceUnavailable || strings.TrimSpace(GetClass.Contract.Interface.Reason) == "" { + t.Error("get-class must publish a precise unavailable Interface") + } +} + +func TestCrossPlatformCoverageAttendanceProjectorsDistinguishEmptyFromBroken(t *testing.T) { + explicitEmpty := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{}, + "adjustmentList": []any{}, + "atRuleList": []any{}, + }, + } + if got, err := searchClassProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty class list must succeed: got=%v err=%v", got, err) + } + if got, err := searchRuleProject(explicitEmpty, "attendance-wukong/test", "result.adjustmentList"); err != nil || len(got) != 0 { + t.Fatalf("explicit empty rule list must succeed: got=%v err=%v", got, err) + } + if got, err := searchGroupProject(explicitEmpty); err != nil || len(got) != 0 { + t.Fatalf("explicit empty group list must succeed: got=%v err=%v", got, err) + } + + broken := []map[string]any{ + {}, + {"success": false, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"items": "not-an-array"}}, + {"success": true, "result": map[string]any{"items": []any{map[string]any{"name": "missing-id"}}}}, + } + for index, data := range broken { + if got, err := searchClassProject(data); err == nil { + t.Errorf("broken class response %d returned success: %v", index, got) + } + if got, err := searchGroupProject(data); err == nil { + t.Errorf("broken group response %d returned success: %v", index, got) + } + } + + ruleBroken := map[string]any{ + "success": true, + "result": map[string]any{ + "adjustmentList": []any{map[string]any{"entityVO": map[string]any{"name": "missing-id"}}}, + }, + } + if got, err := searchRuleProject(ruleBroken, "attendance-wukong/test", "result.adjustmentList"); err == nil { + t.Fatalf("rule row without stable ID returned success: %v", got) + } +} + +func TestCrossPlatformCoverageAttendancePaginationRequiresCompletionEvidence(t *testing.T) { + withoutEvidence := map[string]any{"success": true, "result": map[string]any{"items": []any{}}} + if _, _, err := attendancePageEvidence(withoutEvidence, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("missing totalCount/totalPage must not be reported as a complete page") + } + firstPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("nonzero total with empty page must fail closed") + } + complete, extra, err := attendancePageEvidence(firstPage, "attendance-wukong/test", 1, 20, 20) + if err != nil { + t.Fatalf("valid page evidence: %v", err) + } + if complete || extra["nextPage"] != 2 { + t.Fatalf("first page should be incomplete with nextPage=2: complete=%v extra=%v", complete, extra) + } + lastPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(2)}} + complete, extra, err = attendancePageEvidence(lastPage, "attendance-wukong/test", 2, 20, 1) + if err != nil || !complete { + t.Fatalf("last page should be complete: complete=%v extra=%v err=%v", complete, extra, err) + } + conflicting := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalPage": float64(1), "totalCount": float64(21)}} + if _, _, err := attendancePageEvidence(conflicting, "attendance-wukong/test", 1, 20, 20); err == nil { + t.Fatal("conflicting totalPage/totalCount evidence must fail closed") + } + mismatchedPage := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "currentPage": float64(2), "totalCount": float64(0)}} + if _, _, err := attendancePageEvidence(mismatchedPage, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("mismatched currentPage must fail closed") + } + negativeTotal := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": float64(-1)}} + if _, _, err := attendancePageEvidence(negativeTotal, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("negative pagination evidence must fail closed") + } + wrongType := map[string]any{"success": true, "result": map[string]any{"items": []any{}, "totalCount": "0"}} + if _, _, err := attendancePageEvidence(wrongType, "attendance-wukong/test", 1, 20, 0); err == nil { + t.Fatal("wrong pagination type must fail closed") + } +} + +func TestCrossPlatformCoverageAttendanceStableIdentityRejectsZeroBlankAndDuplicates(t *testing.T) { + for _, items := range [][]map[string]any{{{"id": 0}}, {{"id": ""}}, {{"id": 1}, {"id": 1}}} { + if err := attendanceValidatePositiveIntegerIDs(items, "attendance/test", "id"); err == nil { + t.Fatalf("invalid integer identity accepted: %#v", items) + } + } + if err := attendanceValidatePositiveIntegerIDs([]map[string]any{{"id": float64(1)}, {"id": int64(2)}}, "attendance/test", "id"); err != nil { + t.Fatalf("valid integer identities rejected: %v", err) + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": ""}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("blank string identity accepted") + } + if err := attendanceValidateExpectedStrings([]map[string]any{{"approveType": "LEAVE"}, {"approveType": "LEAVE"}}, "attendance/test", "approveType", "LEAVE"); err == nil { + t.Fatal("duplicate string identity accepted") + } +} + +func TestCrossPlatformCoverageAttendanceApproveTemplateIdentityAndBinding(t *testing.T) { + valid := []map[string]any{ + {"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, + {"processCode": "process-2", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}, + } + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil { + t.Fatalf("same approveType with unique processCode rejected: %v", err) + } + valid[0]["processCode"] = " process-1 " + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "TRAVEL"); err != nil || valid[0]["processCode"] != "process-1" { + t.Fatalf("processCode was not normalized: value=%#v err=%v", valid[0]["processCode"], err) + } + valid[0]["approveType"] = "OUT" + valid[1]["approveType"] = "OUT" + if err := attendanceValidateApproveTemplates(valid, "attendance/test", "OUT"); err != nil { + t.Fatalf("OUT templates with unique processCode rejected: %v", err) + } + + invalid := []struct { + name string + items []map[string]any + }{ + {"missing processCode", []map[string]any{{"approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"wrong processCode type", []map[string]any{{"processCode": 1, "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"blank processCode", []map[string]any{{"processCode": " ", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}}}, + {"duplicate processCode", []map[string]any{{"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/1"}, {"processCode": "same", "approveType": "TRAVEL", "submitUrl": "https://example.invalid/2"}}}, + {"missing approveType", []map[string]any{{"processCode": "process-1", "submitUrl": "https://example.invalid/1"}}}, + {"null approveType", []map[string]any{{"processCode": "process-1", "approveType": nil, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType type", []map[string]any{{"processCode": "process-1", "approveType": 1, "submitUrl": "https://example.invalid/1"}}}, + {"wrong approveType", []map[string]any{{"processCode": "process-1", "approveType": "OUT", "submitUrl": "https://example.invalid/1"}}}, + {"missing submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL"}}}, + {"null submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": nil}}}, + {"wrong submitUrl type", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": 1}}}, + {"blank submitUrl", []map[string]any{{"processCode": "process-1", "approveType": "TRAVEL", "submitUrl": " "}}}, + } + for _, tc := range invalid { + t.Run(tc.name, func(t *testing.T) { + if err := attendanceValidateApproveTemplates(tc.items, "attendance/test", "TRAVEL"); err == nil { + t.Fatalf("invalid templates accepted: %#v", tc.items) + } + }) + } + + var schema map[string]any + if err := json.Unmarshal(GetApproveTemplate.Contract.Result.DataSchema, &schema); err != nil { + t.Fatal(err) + } + items := schema["properties"].(map[string]any)["templates"].(map[string]any)["items"].(map[string]any) + required := items["required"].([]any) + for _, name := range []string{"processCode", "approveType", "submitUrl"} { + found := false + for _, value := range required { + if value == name { + found = true + } + } + if !found { + t.Errorf("template schema required=%#v missing %s", required, name) + } + } + if got, want := strings.Join(GetApproveTemplate.Contract.Result.SensitivePaths, ","), "templates.formName,templates.processCode,templates.submitUrl"; got != want { + t.Fatalf("template sensitive paths = %q, want %q", got, want) + } + properties := items["properties"].(map[string]any) + for _, name := range []string{"processCode", "submitUrl"} { + property := properties[name].(map[string]any) + if property["type"] != "string" || property["minLength"] != float64(1) { + t.Errorf("template schema %s = %#v, want nonempty string", name, property) + } + } + approveType := properties["approveType"].(map[string]any) + if approveType["type"] != "string" { + t.Errorf("template schema approveType = %#v, want string", approveType) + } + enum := approveType["enum"].([]any) + values := make([]string, 0, len(enum)) + for _, value := range enum { + values = append(values, value.(string)) + } + if got, want := strings.Join(values, ","), "REPAIR_CHECK,LEAVE,OVERTIME,TRAVEL,OUT"; got != want { + t.Errorf("template approveType enum = %q, want %q", got, want) + } +} diff --git a/internal/shortcut/attendance/common.go b/internal/shortcut/attendance/common.go new file mode 100644 index 00000000..1b1a15e5 --- /dev/null +++ b/internal/shortcut/attendance/common.go @@ -0,0 +1,560 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package attendance + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +const attendanceCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or downstream proof gap is closed." + +func attendanceIntegerObjectResult(description, identity string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验后的考勤业务结果","properties":{%q:{"type":"integer","minimum":1,"description":"与请求精确绑定的稳定业务 ID"}},"required":[%q],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, identity, identity, + )), + } +} + +func attendanceApproveTemplateResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验且绑定请求类型的考勤审批模板","properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效审批模板数量"},"templates":{"type":"array","description":"可发起的考勤审批模板","items":{"type":"object","description":"具备稳定流程身份与提交入口的考勤审批模板","properties":{"processCode":{"type":"string","minLength":1,"description":"模板的稳定唯一流程编码"},"approveType":{"type":"string","enum":["REPAIR_CHECK","LEAVE","OVERTIME","TRAVEL","OUT"],"description":"与请求精确绑定的审批类型"},"submitUrl":{"type":"string","minLength":1,"description":"非空审批提交入口"},"formName":{"type":"string","description":"审批模板展示名称"}},"required":["processCode","approveType","submitUrl"],"additionalProperties":true}}},"required":["count","templates"],"additionalProperties":false}`), + } +} + +func attendanceCollectionResult(collection, description, identity, identityType string) *contract.ResultSpec { + identityConstraint := `"minLength":1` + if identityType == "integer" { + identityConstraint = `"minimum":1` + } + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效业务记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具备稳定身份的考勤业务记录","properties":{%q:{"type":%q,%s,"description":"记录的稳定业务身份"}},"required":[%q],"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, identityType, identityConstraint, identity, collection, + )), + } +} + +func attendanceCursorPagination(cursorParameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: cursorParameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenPublicAttendanceContracts() { + // Composite Shortcut properties are the stable published workflow inputs. + // Execute owns the explicit adapters to the differently named MCP fields; + // redirecting an existing non-empty Schema property requires a versioned + // migration and cannot be folded into this hardening change. + CheckResult.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, {Name: "offset", Property: "offset"}, {Name: "limit", Property: "limit"}} + CheckRecord.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + ListApprove.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "types", Property: "types"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + GetApproveTemplate.Contract.Parameters = []contract.ParamDecl{{Name: "type", Property: "type"}} + GetSchedule.Contract.Parameters = []contract.ParamDecl{{Name: "users", Property: "users"}, {Name: "start", Property: "start"}, {Name: "end", Property: "end"}} + SearchClass.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "filter-type", Property: "filterType"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetClass.Contract.Parameters = []contract.ParamDecl{{Name: "class-id", Property: "classId"}} + SearchAdjustmentRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "overtime-id", Property: "overtimeId"}} + SearchOvertimeRule.Contract.Parameters = []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}} + GetSelfSetting.Contract.Parameters = []contract.ParamDecl{{Name: "setting-scene", Property: "settingScene"}, {Name: "user", Property: "user"}} + + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + identity string + identityType string + cursor string + }{ + {&CheckResult, "records", "严格校验的员工打卡结果", "id", "integer", "offset"}, + {&CheckRecord, "records", "严格校验的员工打卡流水", "id", "integer", ""}, + {&ListApprove, "approvals", "严格校验的考勤审批记录", "id", "integer", ""}, + {&SearchClass, "classes", "严格校验的班次搜索结果", "classId", "integer", "page"}, + {&SearchAdjustmentRule, "rules", "严格校验的补卡规则搜索结果", "ruleId", "integer", "page"}, + {&SearchOvertimeRule, "rules", "严格校验的加班规则搜索结果", "ruleId", "integer", "page"}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = attendanceCollectionResult(item.collection, item.description, item.identity, item.identityType) + if item.cursor != "" { + item.declaration.Contract.Pagination = attendanceCursorPagination(item.cursor) + } + } + CheckResult.Contract.Result.SensitivePaths = []string{"records.corpId", "records.record", "records.userId"} + CheckRecord.Contract.Result.SensitivePaths = []string{"records.baseAddress", "records.baseLatitude", "records.baseLongitude", "records.corpId", "records.deviceId", "records.features", "records.userAddress", "records.userId", "records.userLatitude", "records.userLongitude"} + ListApprove.Contract.Result.SensitivePaths = []string{"approvals.corpId", "approvals.originId", "approvals.subType", "approvals.tagName", "approvals.userId"} + GetApproveTemplate.OutputRollout = output.RolloutUnifiedActive + GetApproveTemplate.Contract.Result = attendanceApproveTemplateResult() + GetApproveTemplate.Contract.Result.SensitivePaths = []string{"templates.formName", "templates.processCode", "templates.submitUrl"} + SearchClass.Contract.Result.SensitivePaths = []string{"classes.name", "classes.ownerName"} + SearchAdjustmentRule.Contract.Result.SensitivePaths = []string{"rules.name"} + SearchOvertimeRule.Contract.Result.SensitivePaths = []string{"rules.name"} + GetOvertimeRule.OutputRollout = output.RolloutUnifiedActive + GetOvertimeRule.Contract.Result = attendanceIntegerObjectResult("严格校验的加班规则详情", "id") + GetOvertimeRule.Contract.Result.SensitivePaths = []string{"value.content", "value.groupIdAndNames", "value.name", "value.owner", "value.ownerList", "value.scopes"} + markAttendanceCompatibilityOnly(&GetSummary) + markAttendanceCompatibilityOnly(&ListLeaveTypes) + markAttendanceCompatibilityOnly(&GetLeaveRecords) + markAttendanceCompatibilityOnly(&GetCheckinRecord) + markAttendanceCompatibilityOnly(&GetSchedule) + markAttendanceUnavailable(&GetClass, "下游 get_class_detail 对搜索得到的真实 classId 返回非空详情,但 shiftVO 不回显 id/classId,Shortcut 无法诚实地把详情精确绑定到请求 ID;需要下游补充稳定 ID 回显。") + markAttendanceCompatibilityOnly(&GetSelfSetting) +} + +func markAttendanceCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: attendanceCompatibilityInterfaceReason, + } +} + +func markAttendanceUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} + +func attendanceCallCollection( + rt *shortcut.RuntimeContext, + product, tool string, + params map[string]any, + collection string, + complete bool, + extra map[string]any, + validate func([]map[string]any) error, + paths ...string, +) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + items, err := responsecheck.RequireObjectCollection(data, product+"/"+tool, paths...) + if err != nil { + return err + } + if validate != nil { + if err := validate(items); err != nil { + return err + } + } + return attendanceOutputCollection(rt, collection, items, complete, extra, false, "") +} + +func attendanceCallValue(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCallObject(rt *shortcut.RuntimeContext, product, tool string, params map[string]any) error { + data, err := rt.CallMCPData(product, tool, params) + if err != nil { + return err + } + value, err := responsecheck.RequireSingleObjectResult(data, product+"/"+tool) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": value}) +} + +func attendanceCollectionPayload(collection string, items []map[string]any, complete bool, extra map[string]any) map[string]any { + payload := map[string]any{ + "count": len(items), + collection: items, + "complete": complete, + } + for key, value := range extra { + payload[key] = value + } + return payload +} + +func attendanceOutputCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, legacyExtra map[string]any, paginated bool, nextToken string) error { + payload := attendanceCollectionPayload(collection, items, complete, legacyExtra) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Count: output.NewCount(len(items))} + if paginated { + pagination, err := output.NewPagination(complete, nextToken) + if err != nil { + return responsecheck.Error("attendance/pagination", "invalid_pagination", err.Error()) + } + meta.Pagination = pagination + } + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func attendanceValidatePositiveIntegerIDs(items []map[string]any, operation string, path ...string) error { + seen := make(map[int64]struct{}, len(items)) + for index, item := range items { + value, ok := attendanceNestedValue(item, path...) + if !ok { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 ID", index)) + } + identity, ok := attendancePositiveInteger(value) + if !ok { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项稳定 ID 必须是大于 0 的整数", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return nil +} + +func attendanceValidateExpectedStrings(items []map[string]any, operation, field, expected string) error { + expected = strings.TrimSpace(expected) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 %s 必须是非空字符串", index, field)) + } + if expected != "" && value != expected { + return responsecheck.Error(operation, "item_identity_mismatch", fmt.Sprintf("第 %d 项 %s 与请求不一致", index, field)) + } + if _, duplicate := seen[value]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 %s 重复", index, field)) + } + seen[value] = struct{}{} + } + return nil +} + +func attendanceValidateApproveTemplates(items []map[string]any, operation, expectedType string) error { + expectedType = strings.TrimSpace(expectedType) + seenProcessCodes := make(map[string]struct{}, len(items)) + for index, item := range items { + processCodeValue, present := item["processCode"] + if !present { + return responsecheck.Error(operation, "missing_item_identity", fmt.Sprintf("第 %d 项缺少稳定 processCode", index)) + } + processCode, ok := processCodeValue.(string) + processCode = strings.TrimSpace(processCode) + if !ok || processCode == "" { + return responsecheck.Error(operation, "invalid_item_identity", fmt.Sprintf("第 %d 项 processCode 必须是非空字符串", index)) + } + item["processCode"] = processCode + if _, duplicate := seenProcessCodes[processCode]; duplicate { + return responsecheck.Error(operation, "duplicate_item_identity", fmt.Sprintf("第 %d 项 processCode 重复", index)) + } + seenProcessCodes[processCode] = struct{}{} + + approveType, ok := item["approveType"].(string) + if !ok || approveType != expectedType { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项 approveType 与请求不一致", index)) + } + submitURL, ok := item["submitUrl"].(string) + if !ok || strings.TrimSpace(submitURL) == "" { + return responsecheck.Error(operation, "malformed_item", fmt.Sprintf("第 %d 项缺少非空 submitUrl", index)) + } + } + return nil +} + +func attendanceNestedValue(item map[string]any, path ...string) (any, bool) { + var current any = item + for _, key := range path { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[key] + if !ok { + return nil, false + } + } + return current, true +} + +func attendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} + +func attendanceValidateUserIDs(values []string, maximum int) error { + if len(values) == 0 { + return fmt.Errorf("用户 ID 列表不能为空") + } + if maximum > 0 && len(values) > maximum { + return fmt.Errorf("用户 ID 数量不能超过 %d", maximum) + } + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return fmt.Errorf("用户 ID 不能为空字符串") + } + if value != trimmed { + return fmt.Errorf("用户 ID 不能包含首尾空白") + } + if _, duplicate := seen[trimmed]; duplicate { + return fmt.Errorf("用户 ID 不能重复") + } + seen[trimmed] = struct{}{} + } + return nil +} + +func attendanceValidateUserAndTimeBinding(items []map[string]any, operation string, requestedUsers []string, userField, timeField string, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + rawUser, present := item[userField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + user, ok := rawUser.(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项用户身份回显必须是非空字符串", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + rawTimestamp, present := item[timeField] + if !present { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少时间回显", index)) + } + timestamp, ok := attendancePositiveInteger(rawTimestamp) + if !ok { + return responsecheck.Error(operation, "malformed_request_binding", fmt.Sprintf("第 %d 项时间回显必须是大于 0 的整数毫秒时间戳", index)) + } + if timestamp < startMillis || timestamp > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项时间不在请求范围内", index)) + } + } + return nil +} + +func attendanceValidateApprovalBinding(items []map[string]any, operation string, requestedUsers []string, requestedTypes map[int]struct{}, startMillis, endMillis int64) error { + users := make(map[string]struct{}, len(requestedUsers)) + for _, user := range requestedUsers { + users[user] = struct{}{} + } + for index, item := range items { + user, ok := item["userId"].(string) + user = strings.TrimSpace(user) + if !ok || user == "" { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少用户身份回显", index)) + } + if _, requested := users[user]; !requested { + return responsecheck.Error(operation, "request_identity_mismatch", fmt.Sprintf("第 %d 项用户身份不在请求集合中", index)) + } + bizType, ok := attendancePositiveInteger(item["bizType"]) + if !ok { + return responsecheck.Error(operation, "missing_request_binding", fmt.Sprintf("第 %d 项缺少审批类型回显", index)) + } + if _, requested := requestedTypes[int(bizType)]; !requested { + return responsecheck.Error(operation, "request_type_mismatch", fmt.Sprintf("第 %d 项审批类型不在请求集合中", index)) + } + begin, beginOK := attendancePositiveInteger(item["beginTime"]) + end, endOK := attendancePositiveInteger(item["endTime"]) + if !beginOK || !endOK || end < begin || end < startMillis || begin > endMillis { + return responsecheck.Error(operation, "request_range_mismatch", fmt.Sprintf("第 %d 项审批时间不与请求范围相交", index)) + } + } + return nil +} + +func attendanceValidateMonthRange(startText, endText string) error { + start, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(startText), time.Local) + if err != nil { + return fmt.Errorf("--start 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + end, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(endText), time.Local) + if err != nil { + return fmt.Errorf("--end 日期格式错误,应为 YYYY-MM-DD: %w", err) + } + if end.Before(start) { + return fmt.Errorf("--end 不能早于 --start") + } + if end.After(start.AddDate(0, 1, 0)) { + return fmt.Errorf("--start 到 --end 的跨度不能超过 1 个月") + } + return nil +} + +func attendanceValidatePageRequest(rt *shortcut.RuntimeContext) error { + _, _, err := attendancePageInput(rt) + return err +} + +func attendancePageEvidence(data map[string]any, operation string, page, limit, itemCount int) (bool, map[string]any, error) { + if page < 1 || limit < 1 || itemCount < 0 || itemCount > limit { + return false, nil, responsecheck.Error(operation, "invalid_pagination_request", "页码、页大小或当前项数无效") + } + result, err := responsecheck.RequireObjectResult(data, operation) + if err != nil { + return false, nil, err + } + extra := map[string]any{"page": page, "limit": limit} + if raw, present := result["currentPage"]; present { + currentPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 currentPage 必须是整数") + } + if currentPage != page { + return false, nil, responsecheck.Error(operation, "pagination_page_mismatch", "服务端 currentPage 与请求页码不一致") + } + } + var evidence []bool + var totalPageValue, totalCountValue *int + if raw, present := result["totalPage"]; present { + totalPage, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 必须是整数") + } + if totalPage < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalPage 不能为负数") + } + extra["totalPage"] = totalPage + totalPageValue = &totalPage + if totalPage == 0 { + if page != 1 || itemCount != 0 { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "totalPage=0 只允许第一页为空") + } + evidence = append(evidence, true) + } else { + if page > totalPage { + return false, nil, responsecheck.Error(operation, "pagination_page_out_of_range", "请求页码超过服务端 totalPage") + } + evidence = append(evidence, page == totalPage) + } + } + if raw, present := result["totalCount"]; present { + totalCount, ok := attendanceInt(raw) + if !ok { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 必须是整数") + } + if totalCount < 0 { + return false, nil, responsecheck.Error(operation, "invalid_pagination_evidence", "服务端 totalCount 不能为负数") + } + extra["totalCount"] = totalCount + totalCountValue = &totalCount + if totalCount > 0 && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "empty_page_with_nonzero_total", "totalCount>0 但当前集合为空,不能证明前进或终止") + } + pageStart := (page - 1) * limit + if pageStart > totalCount || pageStart+itemCount > totalCount { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "当前页项数与 totalCount/page/limit 矛盾") + } + evidence = append(evidence, page*limit >= totalCount) + } + if len(evidence) == 0 { + return false, nil, responsecheck.Error(operation, "missing_pagination_evidence", "分页响应缺少 totalCount/totalPage,无法证明当前页是否完整") + } + complete := evidence[0] + for _, candidate := range evidence[1:] { + if candidate != complete { + return false, nil, responsecheck.Error(operation, "conflicting_pagination_evidence", "服务端 totalCount 与 totalPage 对当前页是否完成给出矛盾证据") + } + } + if totalPageValue != nil && totalCountValue != nil { + expectedPages := 0 + if *totalCountValue > 0 { + expectedPages = (*totalCountValue + limit - 1) / limit + } + if *totalPageValue != expectedPages { + return false, nil, responsecheck.Error(operation, "pagination_total_mismatch", "totalPage 与 totalCount/limit 矛盾") + } + } + if !complete && itemCount == 0 { + return false, nil, responsecheck.Error(operation, "pagination_no_progress", "未完成页没有返回任何稳定项,无法安全前进") + } + if !complete && itemCount != limit { + return false, nil, responsecheck.Error(operation, "pagination_short_page", "未完成页返回项数小于 limit,与继续分页证据矛盾") + } + if complete && totalCountValue != nil { + pageStart := (page - 1) * limit + if pageStart+itemCount != *totalCountValue { + return false, nil, responsecheck.Error(operation, "pagination_count_mismatch", "终止页项数未精确覆盖 totalCount") + } + } + if !complete { + extra["nextPage"] = page + 1 + } + return complete, extra, nil +} + +func attendancePageInput(rt *shortcut.RuntimeContext) (int, int, error) { + page, limit := rt.Int("page"), rt.Int("limit") + if page < 1 { + return 0, 0, fmt.Errorf("--page 必须大于等于 1") + } + if limit < 1 || limit > 200 { + return 0, 0, fmt.Errorf("--limit 必须在 1 到 200 之间") + } + return page, limit, nil +} + +func attendanceInt(value any) (int, bool) { + switch number := value.(type) { + case int: + return number, true + case int32: + return int(number), true + case int64: + return int(number), true + case float64: + if number != float64(int(number)) { + return 0, false + } + return int(number), true + default: + return 0, false + } +} diff --git a/internal/shortcut/attendance/rule_class_projection_test.go b/internal/shortcut/attendance/rule_class_projection_test.go index 1d6e0165..149ecb22 100644 --- a/internal/shortcut/attendance/rule_class_projection_test.go +++ b/internal/shortcut/attendance/rule_class_projection_test.go @@ -18,12 +18,12 @@ import ( "testing" ) -// TestSearchClassProjectShiftVOShape guards against projection-data-loss: +// TestCrossPlatformCoverageSearchClassProjectShiftVOShape guards against projection-data-loss: // get_class_list nests the list under result.items and wraps each shift's // identity under shiftVO. The projection must unwrap shiftVO or +search-class // silently returns empty despite the backend returning shifts. -func TestSearchClassProjectShiftVOShape(t *testing.T) { - const raw = `{"result":{"items":[ +func TestCrossPlatformCoverageSearchClassProjectShiftVOShape(t *testing.T) { + const raw = `{"success":true,"result":{"items":[ {"shiftVO":{"id":957395083,"name":"default shift"}}, {"shiftVO":{"id":957395084,"name":"morning shift"}} ]}}` @@ -31,7 +31,10 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := searchClassProject(data) + got, err := searchClassProject(data) + if err != nil { + t.Fatalf("project class response: %v", err) + } if len(got) != 2 { t.Fatalf("lower/upper mismatch: 2 shifts in backend, projection returned %d (%v)", len(got), got) } @@ -40,18 +43,18 @@ func TestSearchClassProjectShiftVOShape(t *testing.T) { } } -// TestSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) +// TestCrossPlatformCoverageSearchRuleProjectEntityVOShape guards get_overtime_rule (result.atRuleList) // and get_adjustment_rule (result.adjustmentList), both wrapping the rule under // entityVO. The resolver must probe those keys and unwrap entityVO or // +search-overtime-rule / +search-adjustment-rule silently return empty. -func TestSearchRuleProjectEntityVOShape(t *testing.T) { +func TestCrossPlatformCoverageSearchRuleProjectEntityVOShape(t *testing.T) { for _, tc := range []struct { name, key string }{ {"overtime", "atRuleList"}, {"adjustment", "adjustmentList"}, } { - raw := `{"result":{"` + tc.key + `":[ + raw := `{"success":true,"result":{"` + tc.key + `":[ {"entityVO":{"id":11,"name":"weekday overtime"},"permissionVO":{}}, {"entityVO":{"id":12,"name":"holiday overtime"},"permissionVO":{}} ]}}` @@ -59,7 +62,10 @@ func TestSearchRuleProjectEntityVOShape(t *testing.T) { if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("[%s] unmarshal fixture: %v", tc.name, err) } - got := searchRuleProject(data) + got, err := searchRuleProject(data, "attendance-wukong/test", "result."+tc.key) + if err != nil { + t.Fatalf("[%s] project rule response: %v", tc.name, err) + } if len(got) != 2 { t.Fatalf("[%s] lower/upper mismatch: result.%s has 2 entries, projection returned %d", tc.name, tc.key, len(got)) } diff --git a/internal/shortcut/builtin/attendance_semantic_catalog_test.go b/internal/shortcut/builtin/attendance_semantic_catalog_test.go new file mode 100644 index 00000000..9a8b0f65 --- /dev/null +++ b/internal/shortcut/builtin/attendance_semantic_catalog_test.go @@ -0,0 +1,114 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageAttendanceSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_attendance.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "attendance" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service != "attendance" { + continue + } + if _, exists := registered[item.Command]; exists { + t.Fatalf("duplicate %s", item.Command) + } + registered[item.Command] = item + } + if len(registered) != 35 || len(source.Shortcuts) != 35 { + t.Fatalf("registered/catalog = %d/%d, want 35/35", len(registered), len(source.Shortcuts)) + } + public := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+get-adjustment-rule": true, + "+get-checkin-record": true, + "+get-leave-records": true, + "+get-self-setting": true, + "+get-schedule": true, + "+get-summary": true, + "+list-leave-types": true, + "+my-attendance": true, + "+query-report-data": true, + "+search-group": true, + "+this-month": true, + } + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } else if availability == shortcut.AvailabilityAvailable || item.Hidden == record.CompatibilityVisible { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + if availability == shortcut.AvailabilityUnavailable && item.Contract.Interface != nil { + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || compatibilityVisible != 11 { + t.Fatalf("public/compatibility-visible attendance shortcuts = %d/%d, want 8/11", public, compatibilityVisible) + } +} diff --git a/internal/shortcut/builtin/chat_semantic_catalog_test.go b/internal/shortcut/builtin/chat_semantic_catalog_test.go index b83ec229..ef390ee2 100644 --- a/internal/shortcut/builtin/chat_semantic_catalog_test.go +++ b/internal/shortcut/builtin/chat_semantic_catalog_test.go @@ -17,13 +17,14 @@ type chatSemanticCatalogFixture struct { Service string `json:"service"` Availability shortcut.Availability `json:"default_availability"` Shortcuts map[string]struct { - Disposition shortcut.SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk shortcut.Risk `json:"risk"` - Availability shortcut.Availability `json:"availability"` - Primary string `json:"primary"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition shortcut.SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk shortcut.Risk `json:"risk"` + Availability shortcut.Availability `json:"availability"` + Primary string `json:"primary"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible"` + Reviewed bool `json:"reviewed"` } `json:"shortcuts"` } diff --git a/internal/shortcut/builtin/ding_semantic_catalog_test.go b/internal/shortcut/builtin/ding_semantic_catalog_test.go new file mode 100644 index 00000000..af96a75d --- /dev/null +++ b/internal/shortcut/builtin/ding_semantic_catalog_test.go @@ -0,0 +1,101 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageDINGSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_ding.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "ding" { + registered[item.Command] = item + } + } + if source.Service != "ding" || len(registered) != 5 || len(source.Shortcuts) != 5 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want ding/5/5", source.Service, len(registered), len(source.Shortcuts)) + } + wantCompatibilityVisible := map[string]bool{ + "+list": true, + "+recall-personal": true, + "+send-personal": true, + } + wantCompatibilityAvailability := map[string]shortcut.Availability{ + "+list": shortcut.AvailabilityUnavailable, + "+recall-personal": shortcut.AvailabilityAvailable, + "+send-personal": shortcut.AvailabilityAvailable, + } + public, unavailable, compatibilityVisible := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != wantCompatibilityAvailability[command] || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("ding", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 2 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/2/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + } +} diff --git a/internal/shortcut/builtin/mail_semantic_catalog_test.go b/internal/shortcut/builtin/mail_semantic_catalog_test.go new file mode 100644 index 00000000..63e3dd03 --- /dev/null +++ b/internal/shortcut/builtin/mail_semantic_catalog_test.go @@ -0,0 +1,111 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_mail.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + if source.Service != "mail" { + t.Fatalf("service = %q", source.Service) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "mail" { + registered[item.Command] = item + } + } + if len(registered) != 18 || len(source.Shortcuts) != 18 { + t.Fatalf("registered/catalog = %d/%d, want 18/18", len(registered), len(source.Shortcuts)) + } + var missing, stale []string + public := 0 + unavailable := 0 + compatibilityVisible := 0 + wantCompatibilityVisible := map[string]bool{ + "+contact-list": true, + "+recent-mail": true, + "+tag-list": true, + "+template-list": true, + "+thread-list": true, + "+unread-mail": true, + } + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s: semantic review facts drifted", command) + } + if item.Risk != record.Risk { + t.Errorf("%s: risk=%q want=%q", command, item.Risk, record.Risk) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || item.Availability != shortcut.AvailabilityAvailable { + t.Errorf("%s: public available shortcut is not executable/visible", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || strings.TrimSpace(item.Safety.Effect) == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s: public shortcut lacks contract/safety/result/unified output", command) + } + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + if item.Hidden == record.CompatibilityVisible || item.Availability != shortcut.AvailabilityUnavailable { + t.Errorf("%s: unavailable shortcut compatibility visibility drifted", command) + } + wantInterfaceAvailability := "unavailable" + if record.CompatibilityVisible { + wantInterfaceAvailability = "available" + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != wantInterfaceAvailability || strings.TrimSpace(item.Contract.Interface.Reason) == "" { + t.Errorf("%s: runtime compatibility interface is not explicit", command) + } + if item.Contract.Result != nil || item.Contract.Pagination != nil || item.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s: unavailable runtime still publishes result/pagination/unified rollout", command) + } + } + if record.CompatibilityVisible { + compatibilityVisible++ + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s: compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 8 || unavailable != 10 || compatibilityVisible != 6 { + t.Fatalf("mail public/unavailable/compatibility-visible shortcuts = %d/%d/%d, want 8/10/6", public, unavailable, compatibilityVisible) + } +} diff --git a/internal/shortcut/builtin/oa_semantic_catalog_test.go b/internal/shortcut/builtin/oa_semantic_catalog_test.go new file mode 100644 index 00000000..9efc0bef --- /dev/null +++ b/internal/shortcut/builtin/oa_semantic_catalog_test.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageOASemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_oa.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "oa" { + registered[item.Command] = item + } + } + if source.Service != "oa" || len(registered) != 10 || len(source.Shortcuts) != 10 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want oa/10/10", source.Service, len(registered), len(source.Shortcuts)) + } + wantCompatibilityVisible := map[string]bool{ + "+list-cc": true, + "+list-executed": true, + "+list-forms": true, + "+list-pending": true, + "+list-submitted": true, + "+my-initiated": true, + } + public, unavailable, compatibilityVisible := 0, 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if item.Contract.Interface == nil || item.Contract.Interface.Availability != string(availability) { + t.Errorf("%s final interface availability=%v, want %q", command, item.Contract.Interface, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if record.CompatibilityVisible { + compatibilityVisible++ + if item.Hidden || !item.CompatibilityVisible || availability != shortcut.AvailabilityAvailable || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s compatibility-visible boundary drift", command) + } + } else if !item.Hidden || item.CompatibilityVisible || shortcut.InPublicCatalog("oa", command) { + t.Errorf("%s nonpublic shortcut visibility drift", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + if record.CompatibilityVisible != wantCompatibilityVisible[command] { + t.Errorf("%s compatibility-visible=%v, want %v", command, record.CompatibilityVisible, wantCompatibilityVisible[command]) + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 1 || unavailable != 0 || compatibilityVisible != len(wantCompatibilityVisible) { + t.Fatalf("public/unavailable/compatibility-visible=%d/%d/%d, want 1/0/%d", public, unavailable, compatibilityVisible, len(wantCompatibilityVisible)) + } +} diff --git a/internal/shortcut/builtin/report_semantic_catalog_test.go b/internal/shortcut/builtin/report_semantic_catalog_test.go new file mode 100644 index 00000000..7e2e66de --- /dev/null +++ b/internal/shortcut/builtin/report_semantic_catalog_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package builtin_test + +import ( + "encoding/json" + "os" + "sort" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageReportSemanticCatalogExactlyCoversRegisteredSurface(t *testing.T) { + raw, err := os.ReadFile("../semantic_catalog_report.json") + if err != nil { + t.Fatal(err) + } + var source chatSemanticCatalogFixture + if err := json.Unmarshal(raw, &source); err != nil { + t.Fatal(err) + } + registered := map[string]shortcut.Shortcut{} + for _, item := range shortcut.All() { + if item.Service == "report" { + registered[item.Command] = item + } + } + if source.Service != "report" || len(registered) != 4 || len(source.Shortcuts) != 4 { + t.Fatalf("service/registered/catalog=%s/%d/%d, want report/4/4", source.Service, len(registered), len(source.Shortcuts)) + } + public, unavailable := 0, 0 + var missing, stale []string + for command, item := range registered { + record, ok := source.Shortcuts[command] + if !ok { + missing = append(missing, command) + continue + } + availability := record.Availability + if availability == "" { + availability = source.Availability + } + if !record.Reviewed || !item.SemanticReviewed || strings.TrimSpace(item.SemanticDelta) == "" || item.SemanticDelta != record.SemanticDelta { + t.Errorf("%s semantic review facts drifted", command) + } + if item.Risk != record.Risk || item.Availability != availability { + t.Errorf("%s risk/availability=%q/%q want=%q/%q", command, item.Risk, item.Availability, record.Risk, availability) + } + if record.Public { + public++ + if availability != shortcut.AvailabilityAvailable || item.Hidden || !shortcut.InPublicCatalog("report", command) { + t.Errorf("%s public availability/visibility drift", command) + } + if item.Contract.Empty() || item.Contract.Result == nil || item.Safety.Effect == "" || item.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s lacks public Contract/Safety/Result/unified output", command) + } + } else { + if !item.Hidden || shortcut.InPublicCatalog("report", command) { + t.Errorf("%s nonpublic shortcut is visible", command) + } + if availability == shortcut.AvailabilityUnavailable { + unavailable++ + } + } + } + for command := range source.Shortcuts { + if _, ok := registered[command]; !ok { + stale = append(stale, command) + } + } + sort.Strings(missing) + sort.Strings(stale) + if len(missing) > 0 || len(stale) > 0 { + t.Fatalf("catalog mismatch: missing=%v stale=%v", missing, stale) + } + if public != 4 || unavailable != 0 { + t.Fatalf("public/unavailable=%d/%d, want 4/0", public, unavailable) + } +} diff --git a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go index e3f2ec4a..342d07b1 100644 --- a/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go +++ b/internal/shortcut/calendar/calendar_cross_platform_coverage_test.go @@ -72,14 +72,24 @@ func TestCrossPlatformCoverageCalendarListRequiresExplicitCollectionAndPaginatio if err != nil || len(events) != 0 || !page.Known || page.HasMore { t.Fatalf("explicit empty: events=%v page=%+v err=%v", events, page, err) } + var serviceEmptySentinel map[string]any + if err := json.Unmarshal([]byte(`{"success":true,"result":{"events":[{"attendees":null,"categories":null,"meetingRooms":null,"reminders":null}],"hasMore":false}}`), &serviceEmptySentinel); err != nil { + t.Fatal(err) + } + events, page, err = eventListProject(serviceEmptySentinel) + if err != nil || events == nil || len(events) != 0 || !page.Known || page.HasMore { + t.Fatalf("service empty sentinel: events=%#v page=%+v err=%v", events, page, err) + } for name, payload := range map[string]string{ - "missing collection": `{"success":true,"result":{"hasMore":false}}`, - "bad collection": `{"success":true,"result":{"events":{}}}`, - "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`, - "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`, - "missing pagination": `{"success":true,"result":{"events":[]}}`, - "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`, + "missing collection": `{"success":true,"result":{"hasMore":false}}`, + "bad collection": `{"success":true,"result":{"events":{}}}`, + "bad item": `{"success":true,"result":{"events":["bad"],"hasMore":false}}`, + "empty item": `{"success":true,"result":{"events":[{}],"hasMore":false}}`, + "unknown null item": `{"success":true,"result":{"events":[{"summary":null}],"hasMore":false}}`, + "sentinel with cursor": `{"success":true,"result":{"events":[{"attendees":null}],"hasMore":false,"nextCursor":"unexpected"}}`, + "missing pagination": `{"success":true,"result":{"events":[]}}`, + "missing next cursor": `{"success":true,"result":{"events":[],"hasMore":true}}`, } { t.Run(name, func(t *testing.T) { var data map[string]any diff --git a/internal/shortcut/calendar/common.go b/internal/shortcut/calendar/common.go index e0d7c27f..f54b6fa0 100644 --- a/internal/shortcut/calendar/common.go +++ b/internal/shortcut/calendar/common.go @@ -16,6 +16,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat" ) const calendarCompositeReason = "Reviewed Calendar Shortcut composite: the executable CLI owns strict response validation, truthful pagination, optional multi-step writes, read-back verification, and confirmation; no single MCP interface represents the complete command contract." @@ -205,6 +206,9 @@ func requireCalendarCollection(data map[string]any, operation string, keys ...st if !ok { return nil, nil, calendarResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 字段不是数组", key)) } + if key == "events" { + items, _ = calendarcompat.NormalizeTerminalEmptyEvents(items, container) + } if err := validateCalendarCollectionItems(items, operation, key); err != nil { return nil, nil, err } diff --git a/internal/shortcut/calendarcompat/empty_events.go b/internal/shortcut/calendarcompat/empty_events.go new file mode 100644 index 00000000..fc96e300 --- /dev/null +++ b/internal/shortcut/calendarcompat/empty_events.go @@ -0,0 +1,48 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package calendarcompat contains narrow compatibility adapters for observed +// Calendar wire shapes shared by the calendar and smart Shortcut packages. +package calendarcompat + +// NormalizeTerminalEmptyEvents recognizes the Calendar service's legacy empty +// page sentinel. Some deployments encode an exhausted empty page as one object +// whose collection fields are all null instead of events:[]. Only that exact +// terminal shape is normalized; arbitrary id-less or mixed event rows remain +// available to the strict caller validation and must fail closed. +func NormalizeTerminalEmptyEvents(items []any, container map[string]any) ([]any, bool) { + if len(items) != 1 || container == nil { + return items, false + } + hasMore, ok := container["hasMore"].(bool) + if !ok || hasMore || hasNonEmptyCursor(container) { + return items, false + } + placeholder, ok := items[0].(map[string]any) + knownNullFields := map[string]struct{}{ + "attendees": {}, + "categories": {}, + "meetingRooms": {}, + "reminders": {}, + } + if !ok || len(placeholder) != len(knownNullFields) { + return items, false + } + for key, value := range placeholder { + if _, known := knownNullFields[key]; !known || value != nil { + return items, false + } + } + return []any{}, true +} + +func hasNonEmptyCursor(container map[string]any) bool { + for _, key := range []string{"nextCursor", "nextToken", "pageToken"} { + value, present := container[key] + if !present || value == nil || value == "" { + continue + } + return true + } + return false +} diff --git a/internal/shortcut/calendarcompat/empty_events_test.go b/internal/shortcut/calendarcompat/empty_events_test.go new file mode 100644 index 00000000..c5e5d930 --- /dev/null +++ b/internal/shortcut/calendarcompat/empty_events_test.go @@ -0,0 +1,44 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package calendarcompat + +import "testing" + +func TestCrossPlatformCoverageNormalizeTerminalEmptyEvents(t *testing.T) { + sentinel := []any{map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil, + }} + if got, ok := NormalizeTerminalEmptyEvents(sentinel, map[string]any{"hasMore": false}); !ok || got == nil || len(got) != 0 { + t.Fatalf("terminal sentinel = %#v, %v; want explicit empty slice", got, ok) + } + + for name, items := range map[string][]any{ + "empty object": {map[string]any{}}, + "missing fields": {map[string]any{"attendees": nil}}, + "unknown field": {map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "summary": nil, + }}, + "non-null field": {map[string]any{ + "attendees": []any{}, "categories": nil, "meetingRooms": nil, "reminders": nil, + }}, + "mixed rows": {sentinel[0], map[string]any{"id": "event-1"}}, + } { + t.Run(name, func(t *testing.T) { + if _, ok := NormalizeTerminalEmptyEvents(items, map[string]any{"hasMore": false}); ok { + t.Fatalf("invalid sentinel accepted: %#v", items) + } + }) + } + for name, container := range map[string]map[string]any{ + "missing terminal evidence": {}, + "more pages": {"hasMore": true}, + "cursor": {"hasMore": false, "nextCursor": "next"}, + } { + t.Run(name, func(t *testing.T) { + if _, ok := NormalizeTerminalEmptyEvents(sentinel, container); ok { + t.Fatalf("non-terminal sentinel accepted with %#v", container) + } + }) + } +} diff --git a/internal/shortcut/chat/coverage_completion_test.go b/internal/shortcut/chat/coverage_completion_test.go index 0bdbe1ab..2ea6714a 100644 --- a/internal/shortcut/chat/coverage_completion_test.go +++ b/internal/shortcut/chat/coverage_completion_test.go @@ -437,7 +437,7 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T }{ { name: "user group mentions", - args: []string{"--identity", "user", "--group", "cid", "--text", "x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, + args: []string{"--identity", "user", "--group", "cid", "--text", "@" + fixtureCurrentDOpenID + " <@" + fixtureCurrentDOpenID2 + "> x", "--at-open-dingtalk-ids", fixtureCurrentDOpenID + "," + fixtureCurrentDOpenID2, "--at-all"}, want: map[string]any{"atOpenDingTalkIds": []string{fixtureCurrentDOpenID, fixtureCurrentDOpenID2}, "atAll": true}, }, { @@ -505,6 +505,69 @@ func TestCrossPlatformCoverageUnifiedSendOptionalArgumentsAndErrors(t *testing.T } } +func TestCrossPlatformCoverageMessagesSendRejectsMismatchedCurrentUserMentions(t *testing.T) { + tests := []struct { + name string + body string + atIDs string + atAll bool + wantErr string + }{ + { + name: "declared member missing from body", + body: "请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, + { + name: "declared member only appears as a longer token prefix", + body: "@" + fixtureCurrentDOpenID + "_suffix 请查收", + atIDs: fixtureCurrentDOpenID, + wantErr: "必须在正文中使用对应", + }, + { + name: "body member missing from declaration", + body: "<@" + fixtureCurrentDOpenID + "> 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + { + name: "body at-all missing from declaration", + body: "<@all> 请查收", + wantErr: "必须同时指定 --at-all", + }, + { + name: "bare body member missing from declaration", + body: "@" + fixtureCurrentDOpenID + " 请查收", + wantErr: "必须同时通过 --at-open-dingtalk-ids 声明", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--group", "cid", "--markdown", tt.body, "--yes", + } + if tt.atIDs != "" { + args = append(args, "--at-open-dingtalk-ids", tt.atIDs) + } + if tt.atAll { + args = append(args, "--at-all") + } + root.SetArgs(args) + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("error = %v, want %q", err, tt.wantErr) + } + if len(fake.calls) != 0 { + t.Fatalf("invalid mention contract reached lower service: %#v", fake.calls) + } + }) + } +} + func TestCrossPlatformCoverageUnifiedSendUnsupportedIdentityGuard(t *testing.T) { flags := append([]shortcut.Flag(nil), MessagesSend.Flags...) for i := range flags { diff --git a/internal/shortcut/chat/gap_fill_test.go b/internal/shortcut/chat/gap_fill_test.go index 2526af79..0946632c 100644 --- a/internal/shortcut/chat/gap_fill_test.go +++ b/internal/shortcut/chat/gap_fill_test.go @@ -585,6 +585,119 @@ func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileFlow(t *testing.T) } } +func TestCrossPlatformCoverageMessagesSendCurrentUserDirectLocalFileUsesTransportSpecificTargets(t *testing.T) { + t.Chdir(t.TempDir()) + if err := os.WriteFile("direct.bin", []byte("direct-file"), 0o600); err != nil { + t.Fatal(err) + } + uploadServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + t.Errorf("upload method = %s", r.Method) + } + if _, err := io.Copy(io.Discard, r.Body); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusOK) + })) + t.Cleanup(uploadServer.Close) + + tests := []struct { + name string + targetArgs []string + requestedType string + wantOpenID string + wantUserResolve bool + }{ + { + name: "explicit open id file", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID}, + requestedType: "file", + wantOpenID: fixtureCurrentDOpenID, + }, + { + name: "resolved user audio", + targetArgs: []string{"--user", "user-id"}, + requestedType: "audio", + wantOpenID: "D-resolved", + wantUserResolve: true, + }, + { + name: "explicit open id video", + targetArgs: []string{"--open-dingtalk-id", fixtureCurrentDOpenID2}, + requestedType: "video", + wantOpenID: fixtureCurrentDOpenID2, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &larkAlignmentCaller{responses: map[string]string{ + "im/init_conversation_file_upload": `{"resourceUrl":"` + uploadServer.URL + `","uploadKey":"upload-key"}`, + "im/commit_conversation_file_upload": `{"result":{"dentryId":31,"spaceId":41}}`, + "chat/send_personal_message": `{"result":{"openMessageId":"sent-direct-file"}}`, + }} + helpers.InitDeps(fake) + root := newPlatformCoverageRoot() + var output bytes.Buffer + root.SetOut(&output) + args := []string{ + "chat", "+messages-send", "--identity", "user", + "--msg-type", tt.requestedType, + "--file", "./direct.bin", + "--idempotency-key", "direct-" + tt.requestedType, + "--yes", + } + args = append(args, tt.targetArgs...) + root.SetArgs(args) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + callOffset := 0 + if tt.wantUserResolve { + if len(fake.calls) == 0 || fake.calls[0].product != "contact" || + fake.calls[0].tool != "search_contact_by_key_word" { + t.Fatalf("resolution call = %#v", fake.calls) + } + callOffset = 1 + } + if len(fake.calls) != callOffset+3 { + t.Fatalf("calls = %#v, want resolution plus init, commit, send", fake.calls) + } + initCall := fake.calls[callOffset] + commitCall := fake.calls[callOffset+1] + sendCall := fake.calls[callOffset+2] + for _, call := range []larkAlignmentCall{initCall, commitCall} { + if call.product != "im" || call.args["openDingTalkId"] != tt.wantOpenID { + t.Fatalf("upload call = %#v, want openDingTalkId %q", call, tt.wantOpenID) + } + if _, exists := call.args["receiverOpenDingTalkId"]; exists { + t.Fatalf("send-only receiver field leaked into upload call: %#v", call.args) + } + } + if initCall.tool != "init_conversation_file_upload" || + commitCall.tool != "commit_conversation_file_upload" { + t.Fatalf("upload calls = %#v, %#v", initCall, commitCall) + } + if sendCall.product != "chat" || sendCall.tool != "send_personal_message" || + sendCall.args["receiverOpenDingTalkId"] != tt.wantOpenID { + t.Fatalf("send call = %#v, want receiverOpenDingTalkId %q", sendCall, tt.wantOpenID) + } + if _, exists := sendCall.args["openDingTalkId"]; exists { + t.Fatalf("upload-only target field leaked into send call: %#v", sendCall.args) + } + + var payload map[string]any + if err := json.Unmarshal(output.Bytes(), &payload); err != nil { + t.Fatal(err) + } + if payload["requestedMessageType"] != tt.requestedType || + payload["effectiveMessageType"] != "file" { + t.Fatalf("output = %#v", payload) + } + }) + } +} + func TestCrossPlatformCoverageMessagesSendCurrentUserLocalFileDryRunAndFailures(t *testing.T) { t.Chdir(t.TempDir()) if err := os.WriteFile("fixture.bin", []byte("x"), 0o600); err != nil { diff --git a/internal/shortcut/chat/lark_alignment_test.go b/internal/shortcut/chat/lark_alignment_test.go index cea41825..18ba4296 100644 --- a/internal/shortcut/chat/lark_alignment_test.go +++ b/internal/shortcut/chat/lark_alignment_test.go @@ -10,6 +10,7 @@ import ( "encoding/json" "errors" "reflect" + "strings" "testing" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" @@ -337,8 +338,15 @@ func TestCrossPlatformCoverageMessagesSendRoutesIdentitySpecificTransports(t *te } } if tt.bodyKey == "content" { + rawContent := call.args[tt.bodyKey].(string) + if strings.Contains(rawContent, `\u003c`) || strings.Contains(rawContent, `\u003e`) { + t.Errorf("content = %q; current-user mention tokens must remain literal", rawContent) + } + if !strings.Contains(rawContent, "<@"+fixtureCurrentDOpenID+">") { + t.Errorf("content = %q; missing literal current-user mention token", rawContent) + } var content map[string]string - if err := json.Unmarshal([]byte(call.args[tt.bodyKey].(string)), &content); err != nil { + if err := json.Unmarshal([]byte(rawContent), &content); err != nil { t.Fatal(err) } if content["text"] != tt.body { diff --git a/internal/shortcut/chat/unified_send.go b/internal/shortcut/chat/unified_send.go index 1977d077..670ae6e8 100644 --- a/internal/shortcut/chat/unified_send.go +++ b/internal/shortcut/chat/unified_send.go @@ -11,6 +11,7 @@ import ( "os" "strings" "time" + "unicode" "unicode/utf8" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" @@ -18,6 +19,7 @@ import ( "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/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/jsonutil" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/chatmsg" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver" @@ -181,6 +183,12 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { if contentType != "text" && contentType != "markdown" && (len(atOpenIDs) > 0 || rt.Bool("at-all")) { return apperrors.NewValidation("user image/file/audio/video 当前不接受 @ 参数") } + if (group != "" || chatQuery != "") && (contentType == "text" || contentType == "markdown") { + if err := validateCurrentUserMentionConsistency( + messagesSendBody(rt), atOpenIDs, rt.Bool("at-all")); err != nil { + return err + } + } case "bot": if chatQuery != "" || userQuery != "" { return apperrors.NewValidation("--identity bot 当前不接受 --chat-query 或 --user-query;请传真实群 ID 或批量用户 ID") @@ -234,6 +242,89 @@ func validateMessagesSend(rt *shortcut.RuntimeContext) error { return nil } +func validateCurrentUserMentionConsistency(body string, atOpenIDs []string, atAll bool) error { + declared := make(map[string]struct{}, len(atOpenIDs)) + for _, id := range atOpenIDs { + declared[id] = struct{}{} + } + + missingPlaceholders := make([]string, 0) + for _, id := range atOpenIDs { + if !containsCurrentUserMentionToken(body, id) { + missingPlaceholders = append(missingPlaceholders, id) + } + } + if len(missingPlaceholders) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "--at-open-dingtalk-ids 中的成员必须在正文中使用对应 <@openDingTalkId> 占位符;缺少: %s", + strings.Join(missingPlaceholders, ","), + )) + } + + undeclared := make([]string, 0) + for _, id := range currentUserMentionBodyIDs(body) { + if _, ok := declared[id]; !ok { + undeclared = append(undeclared, id) + } + } + if len(undeclared) > 0 { + return apperrors.NewValidation(fmt.Sprintf( + "正文中的成员 <@openDingTalkId> 占位符必须同时通过 --at-open-dingtalk-ids 声明;未声明: %s", + strings.Join(undeclared, ","), + )) + } + if !atAll && containsCurrentUserMentionToken(body, "all") { + return apperrors.NewValidation("正文中的 <@all> 占位符必须同时指定 --at-all") + } + return nil +} + +func containsCurrentUserMentionToken(body, id string) bool { + placeholder := "@" + id + for searchFrom := 0; ; { + offset := strings.Index(body[searchFrom:], placeholder) + if offset < 0 { + return false + } + end := searchFrom + offset + len(placeholder) + if end == len(body) { + return true + } + next, _ := utf8.DecodeRuneInString(body[end:]) + if !unicode.IsLetter(next) && !unicode.IsDigit(next) && next != '_' && next != '-' { + return true + } + searchFrom = end + } +} + +func currentUserMentionBodyIDs(body string) []string { + ids := make([]string, 0) + for searchFrom := 0; ; { + offset := strings.IndexByte(body[searchFrom:], '@') + if offset < 0 { + break + } + start := searchFrom + offset + 1 + end := start + for end < len(body) { + current := body[end] + if (current < 'a' || current > 'z') && + (current < 'A' || current > 'Z') && + (current < '0' || current > '9') { + break + } + end++ + } + id := body[start:end] + if targetresolver.LooksLikeCurrentDOpenDingTalkID(id) { + ids = appendUniqueShortcutString(ids, id) + } + searchFrom = end + } + return ids +} + func executeMessagesSend(rt *shortcut.RuntimeContext) error { identity := messagesSendIdentity(rt) body := messagesSendBody(rt) @@ -499,7 +590,7 @@ func resolvedUserMarkdownParams( if target.GroupID != "" { body = helpers.NormalizeMessageMentions(body, atOpenIDs, atAll, true) } - content, _ := json.Marshal(map[string]string{"title": title, "text": body}) + content, _ := jsonutil.Marshal(map[string]string{"title": title, "text": body}) params := rt.AddAIMessageTag(map[string]any{ "msgType": "markdown", "content": string(content), @@ -573,8 +664,10 @@ func executeMessagesSendUserFile( if err != nil { return err } - targetArgs := map[string]any{} - addMessagesSendUserTarget(targetArgs, group, openID) + uploadTargetArgs := map[string]any{} + addMessagesSendUserUploadTarget(uploadTargetArgs, group, openID) + sendTargetArgs := map[string]any{} + addMessagesSendUserTarget(sendTargetArgs, group, openID) idempotencyKey := messagesSendIdempotencyKey(rt) if rt.DryRun() { return rt.Output(map[string]any{ @@ -587,6 +680,7 @@ func executeMessagesSendUserFile( { "identity": "user", "tool": "init/commit_conversation_file_upload", + "target": uploadTargetArgs, "file": map[string]any{ "path": rawPath, "name": meta.FileName, @@ -598,7 +692,7 @@ func executeMessagesSendUserFile( "tool": "send_personal_message", "requestedMessageType": requestedType, "effectiveMessageType": "file", - "target": targetArgs, + "target": sendTargetArgs, }, }, }) @@ -607,7 +701,7 @@ func executeMessagesSendUserFile( rt.Command().Context(), messagesSendFileUploadTimeout) defer cancelUpload() commitText, err := helpers.UploadConversationLocalFile( - uploadContext, targetArgs, meta, idempotencyKey) + uploadContext, uploadTargetArgs, meta, idempotencyKey) if err != nil { return err } @@ -653,6 +747,14 @@ func addMessagesSendUserTarget(params map[string]any, group, openID string) { params["receiverOpenDingTalkId"] = openID } +func addMessagesSendUserUploadTarget(params map[string]any, group, openID string) { + if group != "" { + params["openConversationId"] = group + return + } + params["openDingTalkId"] = openID +} + func nonEmptyStringCount(values ...string) int { count := 0 for _, value := range values { diff --git a/internal/shortcut/contact/contact.go b/internal/shortcut/contact/contact.go index df7ad96e..d84b2819 100644 --- a/internal/shortcut/contact/contact.go +++ b/internal/shortcut/contact/contact.go @@ -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)) diff --git a/internal/shortcut/contact/search_mobile_coverage_test.go b/internal/shortcut/contact/search_mobile_coverage_test.go new file mode 100644 index 00000000..06e392d5 --- /dev/null +++ b/internal/shortcut/contact/search_mobile_coverage_test.go @@ -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) + } +} diff --git a/internal/shortcut/ding/common.go b/internal/shortcut/ding/common.go new file mode 100644 index 00000000..13408567 --- /dev/null +++ b/internal/shortcut/ding/common.go @@ -0,0 +1,316 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const ( + dingCompositeReason = "Reviewed DING Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable DING identity checks, truthful pagination, and unified output projection." + dingCompatibilityWriteReason = "Historical DING CLI write compatibility: the command remains executable behind user confirmation, but it is excluded from Agent public discovery because receiver identity and recall terminal-state verification are incomplete." + dingWriteUnavailableReason = "DING mutation is unavailable to Agents until the downstream exposes stable receiver identities and a queryable recall terminal state; isolated self-fixtures already prove stable DING receipts but cannot prove those two facts." +) + +type dingPageEvidence struct { + HasMore bool + Next string +} + +func dingReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func dingWriteSafety(destructive bool) contract.SafetySpec { + effect, risk := "write", "medium" + if destructive { + effect, risk = "destructive", "high" + } + return contract.SafetySpec{Effect: effect, Risk: risk, Confirmation: "user_required", Idempotency: "unknown"} +} + +func dingListResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的 DING 消息页","properties":{"count":{"type":"integer","description":"当前页有效 DING 消息数量"},"messages":{"type":"array","description":"严格验证并投影后的 DING 消息","items":{"type":"object","description":"一条具有稳定 openDingId 的 DING 消息","properties":{"openDingId":{"type":"string","description":"稳定 DING 消息 ID"},"content":{"type":"string","description":"DING 消息内容"},"sourceMessageId":{"type":"string","description":"作为 DING 来源的聊天消息 ID"},"sendTime":{"type":"string","description":"服务端返回的发送时间"},"senderNick":{"type":"string","description":"发送人显示名"}},"required":["openDingId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"当前页是否具有服务端分页终态证据"}},"required":["count","messages","complete"],"additionalProperties":false}`), + SensitivePaths: []string{"messages.content", "messages.senderNick"}, + } +} + +func dingReceiverResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"指定 DING 的严格接收状态","properties":{"count":{"type":"integer","description":"经验证的接收状态数量"},"receivers":{"type":"array","description":"身份匹配的 DING 接收状态","items":{"type":"object","description":"一名接收人的 DING 确认状态","properties":{"openDingId":{"type":"string","description":"与请求一致的稳定 DING ID"},"confirmedStatus":{"type":"integer","description":"服务端 DING 确认状态码"},"receiverNick":{"type":"string","description":"接收人显示名"}},"required":["openDingId","confirmedStatus","receiverNick"],"additionalProperties":false}}},"required":["count","receivers"],"additionalProperties":false}`), + SensitivePaths: []string{"receivers.receiverNick"}, + } +} + +func dingPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func dingContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "ding " + command + availability, reason := contract.InterfaceAvailable, dingCompositeReason + if !available { + availability, reason = contract.InterfaceUnavailable, dingWriteUnavailableReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "ding", Name: name, CanonicalPath: "ding." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: reason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"普通聊天消息使用 chat;写能力在缺少可逆真实 fixture、精确读回或清理证据时不要执行"}, + Examples: examples, + }, + } +} + +func dingResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func dingRequireResult(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, dingResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 DING 调用成功或结果确实为空") + } + success, present := data["success"] + if !present { + return nil, dingResponseError(operation, "missing_success", "DING 响应缺少 success 业务状态") + } + succeeded, ok := success.(bool) + if !ok { + return nil, dingResponseError(operation, "invalid_success_type", fmt.Sprintf("DING 响应 success 应为布尔值,实际为 %T", success)) + } + if !succeeded { + message := "DING 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return nil, dingResponseError(operation, "remote_failure", message) + } + result, ok := data["result"].(map[string]any) + if !ok || result == nil { + return nil, dingResponseError(operation, "missing_result", "DING 成功响应缺少 result 对象") + } + return result, nil +} + +func dingRequireObjectCollection(result map[string]any, operation, field string) ([]map[string]any, error) { + raw, present := result[field] + if !present { + return nil, dingResponseError(operation, "missing_collection", fmt.Sprintf("DING 成功响应缺少 result.%s 数组", field)) + } + values, ok := raw.([]any) + if !ok { + return nil, dingResponseError(operation, "malformed_collection", fmt.Sprintf("DING result.%s 应为数组,实际为 %T", field, raw)) + } + items := make([]map[string]any, 0, len(values)) + for index, value := range values { + item, ok := value.(map[string]any) + if !ok || len(item) == 0 { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING result.%s[%d] 不是非空对象", field, index)) + } + items = append(items, item) + } + return items, nil +} + +func dingRequiredString(item map[string]any, operation, field string, index int) (string, error) { + value, ok := item[field].(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", dingResponseError(operation, "missing_item_identity", fmt.Sprintf("DING 结果第 %d 项缺少非空 %s", index, field)) + } + return value, nil +} + +func dingOptionalString(item map[string]any, field string) (string, error) { + value, present := item[field] + if !present || value == nil { + return "", nil + } + text, ok := value.(string) + if !ok { + return "", fmt.Errorf("字段 %s 应为字符串,实际为 %T", field, value) + } + return strings.TrimSpace(text), nil +} + +func dingInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func dingProjectMessages(data map[string]any, operation string) ([]map[string]any, dingPageEvidence, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, dingPageEvidence{}, err + } + items, err := dingRequireObjectCollection(result, operation, "dingMessages") + if err != nil { + return nil, dingPageEvidence{}, err + } + messages := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, dingPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, dingPageEvidence{}, dingResponseError(operation, "duplicate_item_identity", "当前 DING 页包含重复 openDingId") + } + seen[id] = struct{}{} + projected := map[string]any{"openDingId": id} + for source, target := range map[string]string{ + "dingContent": "content", "dingSourceOpenMessageId": "sourceMessageId", + "sendTime": "sendTime", "senderNick": "senderNick", + } { + value, valueErr := dingOptionalString(item, source) + if valueErr != nil { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 结果第 %d 项%s", index, valueErr.Error())) + } + if value != "" { + projected[target] = value + } + } + messages = append(messages, projected) + } + hasMoreValue, present := result["hasMore"] + if !present { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_pagination", "DING 列表缺少 result.hasMore,不能宣称当前页完整") + } + hasMore, ok := hasMoreValue.(bool) + if !ok { + return nil, dingPageEvidence{}, dingResponseError(operation, "malformed_pagination", "DING result.hasMore 应为布尔值") + } + page := dingPageEvidence{HasMore: hasMore} + if !hasMore { + if next, exists := result["nextCursor"]; exists && next != nil { + if numeric, valid := dingInteger(next); !valid || numeric != 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 仍表示后续页") + } + } + return messages, page, nil + } + if len(messages) == 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "empty_page_with_continuation", "DING 空页仍声明 hasMore=true,无法证明游标可安全推进") + } + next, valid := dingInteger(result["nextCursor"]) + if !valid || next <= 0 { + return nil, dingPageEvidence{}, dingResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 nextCursor") + } + page.Next = strconv.FormatInt(next, 10) + return messages, page, nil +} + +func dingProjectReceivers(data map[string]any, operation, expectedID string) ([]map[string]any, error) { + result, err := dingRequireResult(data, operation) + if err != nil { + return nil, err + } + items, err := dingRequireObjectCollection(result, operation, "receivers") + if err != nil { + return nil, err + } + if len(items) == 0 { + return nil, dingResponseError(operation, "empty_receiver_status", "DING 接收状态显式为空,无法证明目标 DING 的接收状态") + } + receivers := make([]map[string]any, 0, len(items)) + for index, item := range items { + id, identityErr := dingRequiredString(item, operation, "openDingId", index) + if identityErr != nil { + return nil, identityErr + } + if id != expectedID { + return nil, dingResponseError(operation, "identity_mismatch", "DING 接收状态的 openDingId 与请求目标不一致") + } + status, ok := dingInteger(item["confirmedStatus"]) + if !ok { + return nil, dingResponseError(operation, "malformed_item", fmt.Sprintf("DING 接收状态第 %d 项 confirmedStatus 不是整数", index)) + } + nick, nickErr := dingRequiredString(item, operation, "receiverNick", index) + if nickErr != nil { + return nil, nickErr + } + receivers = append(receivers, map[string]any{"openDingId": id, "confirmedStatus": status, "receiverNick": nick}) + } + return receivers, nil +} + +func outputDingPage(rt *shortcut.RuntimeContext, messages []map[string]any, page dingPageEvidence) error { + payload := map[string]any{"count": len(messages), "messages": messages, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return dingResponseError("im/list_ding_messages", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(messages)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func dingUnavailable(operation string) error { + return apperrors.NewDiscovery("该 DING 写 Shortcut 虽有稳定写回执,但下游没有稳定接收人身份和可查询撤回终态,当前不可执行", + apperrors.WithOperation(operation), + apperrors.WithOrigin("shortcut_registry"), + apperrors.WithFailureStage("capability_gate"), + apperrors.WithExecutionStarted(false), + apperrors.WithRetryable(false), + apperrors.WithReason("write_fixture_unavailable"), + ) +} diff --git a/internal/shortcut/ding/ding.go b/internal/shortcut/ding/ding.go index e5e3bb2b..ba2a19a5 100644 --- a/internal/shortcut/ding/ding.go +++ b/internal/shortcut/ding/ding.go @@ -1,226 +1,205 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package ding holds the built-in DING (钉) shortcuts. Tool names and params are -// copied verbatim from internal/helpers/ding.go. Some tools route to the "im" -// MCP server (helper uses callMCPToolOnServer("im", ...)), reflected in Product. +// Package ding registers strict declarative shortcuts for DingTalk DING. package ding import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "strconv" + "strings" + "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) var List = shortcut.Shortcut{ Service: "ding", Command: "+list", Product: "im", - Description: "查询 DING 消息列表", Intent: "当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_list", - CanonicalPath: "ding.shortcut_list", - CLIPath: "ding +list", - PrimaryCLIPath: "ding +list", - }, - Description: "查询 DING 消息列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息列表", - UseWhen: []string{"当你想查看当前身份收到或发出的 DING 消息、回顾有哪些强提醒或获取某条 DING 的 openDingId 以便后续查已读或撤回时使用;可选按类型过滤并用 cursor 翻页,只读不产生副作用。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +list"}, - }, - }, + Description: "查询 DING 消息列表", + Intent: "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+list", "查询 DING 消息列表", + "需要查看 DING 历史、取得稳定 openDingId,或按 ALL、UNREAD、SEND、NEW_COMMENT、DELETED 类型读取一页时使用。", + true, dingListResult(), dingPagination(), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "type", Property: "type"}}, + "dws ding +list --type ALL", "dws ding +list --type DELETED", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标 (可选)"}, - {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "类型 (可选,默认 ALL)"}, + {Name: "cursor", Type: shortcut.FlagInt, Desc: "分页游标,首次不传或传 0"}, + {Name: "type", Type: shortcut.FlagString, Default: "ALL", Desc: "DING 类型:ALL、UNREAD、SEND、NEW_COMMENT 或 DELETED"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页响应必须返回严格前进的正整数 nextCursor"}}, + Tips: []string{"dws ding +list --type ALL", "dws ding +list --type DELETED"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + return nil }, Execute: func(rt *shortcut.RuntimeContext) error { - // type 是服务端必填,空值会报错;不传或传空时兜底为 ALL(对齐 helper)。 - msgType := rt.Str("type") - if msgType == "" { - msgType = "ALL" - } - params := map[string]any{"type": msgType} + params := map[string]any{"type": rt.Str("type")} if rt.Changed("cursor") { params["cursor"] = rt.Int("cursor") } - return rt.CallMCP("list_ding_messages", params) + data, err := rt.CallMCPData("im", "list_ding_messages", params) + if err != nil { + return err + } + messages, page, err := dingProjectMessages(data, "im/list_ding_messages") + if err != nil { + return err + } + if page.HasMore { + current := int64(rt.Int("cursor")) + next, parseErr := strconv.ParseInt(page.Next, 10, 64) + if parseErr != nil || next <= current { + return dingResponseError("im/list_ding_messages", "stalled_cursor", "nextCursor 没有严格前进") + } + } + return outputDingPage(rt, messages, page) }, } var ReceiverStatus = shortcut.Shortcut{ Service: "ding", Command: "+receiver-status", Product: "im", - Description: "查询 DING 消息接收人已读状态", Intent: "当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。", Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_receiver_status", - CanonicalPath: "ding.shortcut_receiver_status", - CLIPath: "ding +receiver-status", - PrimaryCLIPath: "ding +receiver-status", - }, - Description: "查询 DING 消息接收人已读状态", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询 DING 消息接收人已读状态", - UseWhen: []string{"当你发出一条 DING 后想确认每位接收人是否已读、追踪谁还没看到以便催办时使用;需提供该 DING 的 openDingId,返回各接收人的已读/未读状态,只读操作。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +receiver-status --ding-id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, + Description: "查询 DING 消息接收人已读状态", + Intent: "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + Risk: shortcut.RiskRead, + Safety: dingReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract( + "+receiver-status", "查询 DING 消息接收人已读状态", + "已经从 +list 取得稳定 openDingId,需要确认该 DING 的接收状态时使用;这是精确 ID 查询,不是消息搜索。", + true, dingReceiverResult(), nil, + []contract.ParamDecl{{Name: "ding-id", Property: "dingId"}}, + "dws ding +receiver-status --ding-id ", + ), + Flags: []shortcut.Flag{{Name: "ding-id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + Tips: []string{"dws ding +receiver-status --ding-id "}, Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("list_ding_receiver_status", map[string]any{ - "openDingId": rt.Str("ding-id"), - }) + const operation = "im/list_ding_receiver_status" + data, err := rt.CallMCPData("im", "list_ding_receiver_status", map[string]any{"openDingId": rt.Str("ding-id")}) + if err != nil { + return err + } + receivers, err := dingProjectReceivers(data, operation, rt.Str("ding-id")) + if err != nil { + return err + } + payload := map[string]any{"count": len(receivers), "receivers": receivers} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(receivers))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) }, } -var SendPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+send-personal", Product: "im", - Description: "以本人身份发送 DING 给指定人", Intent: "当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。", Risk: shortcut.RiskWrite, - Safety: contract.SafetySpec{ - Effect: "write", Risk: "medium", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_send_personal", - CanonicalPath: "ding.shortcut_send_personal", - CLIPath: "ding +send-personal", - PrimaryCLIPath: "ding +send-personal", - }, - Description: "以本人身份发送 DING 给指定人", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "以本人身份发送 DING 给指定人", - UseWhen: []string{"当你想以自己(而非机器人)的身份直接给某些同事发 DING 强提醒,让对方看到是本人发起时使用;需提供接收人的 openDingTalkId 列表和内容,可选提醒方式与幂等 uuid,会真实向这些人发出 DING。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +send-personal --users --content "}, - }, - }, - Flags: []shortcut.Flag{ +var SendPersonal = compatibilityDingWrite( + "+send-personal", "以本人身份发送 DING 给指定人", + "仅为历史 CLI 兼容保留:明确确认后按原有参数发送;因接收人稳定身份与撤回终态尚不可验证,不进入 Agent 公共发现。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, {Name: "content", Type: shortcut.FlagString, Desc: "消息内容", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式:app、sms 或 call"}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { + []contract.ParamDecl{ + {Name: "users", Property: "users", InterfaceType: "array"}, + {Name: "content", Property: "content"}, {Name: "type", Property: "type"}, {Name: "uuid", Property: "uuid"}, + }, + "dws ding +send-personal --users --content ", + func(rt *shortcut.RuntimeContext) error { + remindType, err := dingPersonalRemindType(rt.Str("type")) + if err != nil { + return err + } params := map[string]any{ "receiverOpenDingTalkIds": rt.StrSlice("users"), "content": rt.Str("content"), - "remindType": rt.Str("type"), + "remindType": remindType, } if rt.Changed("uuid") { params["uuid"] = rt.Str("uuid") } return rt.CallMCP("send_personal_ding", params) }, -} +) -var SendByMessage = shortcut.Shortcut{ - Service: "ding", Command: "+send-by-message", Product: "im", - Description: "针对某条消息发起 DING 提醒", Intent: "当群里已有一条聊天消息需要被重点跟进,你想直接把它变成 DING 去强提醒相关人(而不是另写新内容)时使用;需提供群 openConversationId、该消息 openMessageId 和接收人 openDingTalkId 列表,会真实基于这条消息发出 DING。", Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ +var SendByMessage = unavailableDingWrite( + "+send-by-message", "针对某条消息发起 DING 提醒", + "需要把指定聊天消息转成应用内、短信或电话 DING 时使用;对应 lark-cli 的三种 urgent 任务,但接收人稳定身份与精确撤回终态仍缺失。", + shortcut.RiskWrite, false, + []shortcut.Flag{ {Name: "group", Type: shortcut.FlagString, Desc: "openConversationId", Required: true}, {Name: "message-id", Type: shortcut.FlagString, Desc: "openMessageId", Required: true}, {Name: "users", Type: shortcut.FlagStringSlice, Desc: "接收人 openDingTalkId 列表 (CSV)", Required: true}, - {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式 remindType"}, - {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键 (可选)"}, + {Name: "type", Type: shortcut.FlagString, Default: "app", Desc: "提醒方式", Enum: []string{"app", "sms", "call"}}, + {Name: "uuid", Type: shortcut.FlagString, Desc: "幂等键"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "openConversationId": rt.Str("group"), - "openMessageId": rt.Str("message-id"), - "receiverOpenDingTalkIds": rt.StrSlice("users"), - "remindType": rt.Str("type"), - } - if rt.Changed("uuid") { - params["uuid"] = rt.Str("uuid") - } - return rt.CallMCP("send_ding_by_message", params) + []contract.ParamDecl{ + {Name: "group", Property: "openConversationId"}, {Name: "message-id", Property: "openMessageId"}, + {Name: "users", Property: "receiverOpenDingTalkIds", InterfaceType: "array"}, + {Name: "type", Property: "remindType"}, {Name: "uuid", Property: "uuid"}, }, + "dws ding +send-by-message --group --message-id --users ", +) + +var RecallPersonal = compatibilityDingWrite( + "+recall-personal", "撤回本人发起的 DING", + "仅为历史 CLI 兼容保留:明确确认后按稳定 openDingId 撤回;因终态与残留通知尚不可查询验证,不进入 Agent 公共发现。", + shortcut.RiskHighWrite, true, + []shortcut.Flag{{Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}}, + []contract.ParamDecl{{Name: "id", Property: "id"}}, + "dws ding +recall-personal --id ", + func(rt *shortcut.RuntimeContext) error { + return rt.CallMCP("recall_personal_ding", map[string]any{"openDingId": rt.Str("id")}) + }, +) + +func compatibilityDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string, execute func(*shortcut.RuntimeContext) error) shortcut.Shortcut { + declaration := dingContract(command, description, intent, true, nil, nil, params, example) + declaration.Interface.Reason = dingCompatibilityWriteReason + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: declaration, + Flags: flags, Tips: []string{example}, Execute: execute, + } } -var RecallPersonal = shortcut.Shortcut{ - Service: "ding", Command: "+recall-personal", Product: "im", - Description: "撤回本人发起的 DING", Intent: "当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。", Risk: shortcut.RiskHighWrite, - Safety: contract.SafetySpec{ - Effect: "destructive", Risk: "high", - Confirmation: "user_required", Idempotency: "unknown", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "ding", - Name: "shortcut_recall_personal", - CanonicalPath: "ding.shortcut_recall_personal", - CLIPath: "ding +recall-personal", - PrimaryCLIPath: "ding +recall-personal", +func unavailableDingWrite(command, description, intent string, risk shortcut.Risk, destructive bool, flags []shortcut.Flag, params []contract.ParamDecl, example string) shortcut.Shortcut { + return shortcut.Shortcut{ + Service: "ding", Command: command, Product: "im", + Description: description, Intent: intent, Risk: risk, + Safety: dingWriteSafety(destructive), OutputRollout: output.RolloutUnifiedActive, + Contract: dingContract(command, description, intent, false, nil, nil, params, example), + Flags: flags, Tips: []string{example}, + Execute: func(*shortcut.RuntimeContext) error { + return dingUnavailable("ding/" + command) }, - Description: "撤回本人发起的 DING", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "撤回本人发起的 DING", - UseWhen: []string{"当你以本人身份发出的某条 DING 发错人或内容有误、想收回时使用(对应 send-personal/send-by-message 发出的 DING);需提供该 DING 的 openDingId,会真实撤回它,接收人将不再看到该提醒。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws ding +recall-personal --id "}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "id", Type: shortcut.FlagString, Desc: "openDingId", Required: true}, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - return rt.CallMCP("recall_personal_ding", map[string]any{ - "openDingId": rt.Str("id"), - }) - }, + } +} + +func dingPersonalRemindType(value string) (string, error) { + switch strings.ToLower(strings.TrimSpace(value)) { + case "app": + return "APP", nil + case "sms": + return "SMS", nil + case "call": + return "PHONE", nil + default: + return "", apperrors.NewValidation("--type 必须是 app、sms 或 call") + } } func init() { - shortcut.Register( - List, - ReceiverStatus, - SendPersonal, - SendByMessage, - RecallPersonal, - ) + shortcut.Register(List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal) } diff --git a/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..f5340cf3 --- /dev/null +++ b/internal/shortcut/ding/ding_strict_cross_platform_coverage_test.go @@ -0,0 +1,434 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package ding + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "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 dingCoverageCaller struct { + responses map[string][]string + failures map[string]error + history []string + arguments []map[string]any +} + +func (caller *dingCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + if err := caller.failures[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing DING fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*dingCoverageCaller) Format() string { return "json" } +func (*dingCoverageCaller) DryRun() bool { return false } +func (*dingCoverageCaller) Fields() string { return "" } +func (*dingCoverageCaller) JQ() string { return "" } + +func runDingCoverage(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func runDingCoverageOutput(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, args ...string) ([]byte, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + err := cmd.Execute() + return stdout.Bytes(), err +} + +func TestCrossPlatformCoverageDINGContractsAreStrictTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus, SendPersonal, SendByMessage, RecallPersonal} { + if declaration.Contract.Empty() { + t.Errorf("%s lacks Contract", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + for _, declaration := range []shortcut.Shortcut{List, ReceiverStatus} { + if declaration.Contract.Result == nil { + t.Errorf("%s lacks Result", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s is not interface-available", declaration.Command) + } + } + for _, declaration := range []shortcut.Shortcut{SendPersonal, RecallPersonal} { + if declaration.Safety.Confirmation != "user_required" || declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s compatibility write safety/interface drift", declaration.Command) + } + } + if SendByMessage.Safety.Confirmation != "user_required" || SendByMessage.Contract.Interface == nil || SendByMessage.Contract.Interface.Availability != "unavailable" { + t.Errorf("%s unavailable write safety/interface drift", SendByMessage.Command) + } + if List.Contract.Pagination == nil { + t.Fatal("+list lacks cursor pagination") + } +} + +func TestCrossPlatformCoverageDINGListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": "fixture"}}, + "hasMore": true, + "nextCursor": float64(2), + }, + } + messages, page, err := dingProjectMessages(valid, "im/list_ding_messages") + if err != nil || len(messages) != 1 || !page.HasMore || page.Next != "2" || messages[0]["content"] != "fixture" { + t.Fatalf("valid list projection messages=%#v page=%+v err=%v", messages, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": nil}} + if messages, page, err := dingProjectMessages(empty, "im/list_ding_messages"); err != nil || len(messages) != 0 || page.HasMore { + t.Fatalf("explicit terminal empty page messages=%#v page=%+v err=%v", messages, page, err) + } + fixtures := map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "wrong success": {"success": "true", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "false success": {"success": false, "errorMsg": "fixture failure", "result": map[string]any{"dingMessages": []any{}, "hasMore": false}}, + "missing result": {"success": true}, + "missing collection": {"success": true, "result": map[string]any{"hasMore": false}}, + "wrong collection": {"success": true, "result": map[string]any{"dingMessages": map[string]any{}, "hasMore": false}}, + "bad item": {"success": true, "result": map[string]any{"dingMessages": []any{"bad"}, "hasMore": false}}, + "missing item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"dingContent": "fixture"}}, "hasMore": false}}, + "duplicate item id": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "same"}, map[string]any{"openDingId": "same"}}, "hasMore": false}}, + "missing pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}}}, + "wrong pagination": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": "false"}}, + "empty continuation": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": true, "nextCursor": float64(2)}}, + "missing next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true}}, + "wrong next cursor": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1"}}, "hasMore": true, "nextCursor": "2"}}, + "conflicting terminal": {"success": true, "result": map[string]any{"dingMessages": []any{}, "hasMore": false, "nextCursor": float64(2)}}, + "wrong optional content": {"success": true, "result": map[string]any{"dingMessages": []any{map[string]any{"openDingId": "ding-1", "dingContent": float64(1)}}, "hasMore": false}}, + } + for name, fixture := range fixtures { + if projected, _, projectErr := dingProjectMessages(fixture, "im/list_ding_messages"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if _, _, err := dingProjectMessages(map[string]any{"success": false, "errorMsg": "fixture failure"}, "im/list_ding_messages"); err == nil || !strings.Contains(err.Error(), "fixture failure") { + t.Fatalf("remote failure message was not preserved: %v", err) + } +} + +func TestCrossPlatformCoverageDINGIntegerRepresentations(t *testing.T) { + for name, test := range map[string]struct { + value any + want int64 + }{ + "float64": {value: float64(1), want: 1}, + "int": {value: int(2), want: 2}, + "int64": {value: int64(3), want: 3}, + "json number": {value: json.Number("4"), want: 4}, + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(test.value); !ok || got != test.want { + t.Fatalf("dingInteger(%T(%v))=(%d,%t), want (%d,true)", test.value, test.value, got, ok, test.want) + } + }) + } + for name, value := range map[string]any{ + "nan": math.NaN(), + "fraction": 1.5, + "overflow": math.MaxFloat64, + "bad json number": json.Number("not-an-integer"), + "wrong type": "1", + } { + t.Run(name, func(t *testing.T) { + if got, ok := dingInteger(value); ok { + t.Fatalf("dingInteger(%T(%v))=(%d,true), want invalid", value, value, got) + } + }) + } +} + +func TestCrossPlatformCoverageDINGReceiverResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}} + items, err := dingProjectReceivers(valid, "im/list_ding_receiver_status", "ding-1") + if err != nil || len(items) != 1 || items[0]["confirmedStatus"] != int64(1) { + t.Fatalf("valid receiver projection=%#v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing collection": {"success": true, "result": map[string]any{}}, + "empty collection": {"success": true, "result": map[string]any{"receivers": []any{}}}, + "bad item": {"success": true, "result": map[string]any{"receivers": []any{"bad"}}}, + "missing item id": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "identity mismatch": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "other", "confirmedStatus": float64(1), "receiverNick": "fixture"}}}}, + "wrong status": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": "1", "receiverNick": "fixture"}}}}, + "missing receiver": {"success": true, "result": map[string]any{"receivers": []any{map[string]any{"openDingId": "ding-1", "confirmedStatus": float64(1)}}}}, + } { + if projected, projectErr := dingProjectReceivers(fixture, "im/list_ding_receiver_status", "ding-1"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageDINGListExecutionFailuresPaginationAndLegacy(t *testing.T) { + t.Run("negative cursor validates before call", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{}} + if _, err := runDingCoverage(t, List, caller, "--cursor", "-1"); err == nil || len(caller.history) != 0 { + t.Fatalf("negative cursor error=%v calls=%v", err, caller.history) + } + }) + + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_messages": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, List, caller); err == nil || strings.Join(caller.history, ",") != "list_ding_messages" { + t.Fatalf("transport error=%v calls=%v", err, caller.history) + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller); err == nil { + t.Fatal("malformed empty continuation returned success") + } + }) + + t.Run("stalled cursor", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":2}}`}, + }} + if _, err := runDingCoverage(t, List, caller, "--cursor", "2"); err == nil || !strings.Contains(err.Error(), "nextCursor") { + t.Fatalf("stalled cursor error=%v", err) + } + }) + + t.Run("advancing cursor legacy output", func(t *testing.T) { + legacy := List + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1"}],"hasMore":true,"nextCursor":3}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--cursor", "2") + if err != nil { + t.Fatalf("legacy advancing page: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["nextCursor"] != "3" || payload["complete"] != false { + t.Fatalf("legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["cursor"] != 2 { + t.Fatalf("legacy cursor arguments=%#v", caller.arguments) + } + }) + + t.Run("invalid unified page evidence", func(t *testing.T) { + cmd := corecmd.New(shortcut.FromShortcut(List)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + rt := shortcut.RuntimeContextForTest(cmd, List) + if err := outputDingPage(rt, nil, dingPageEvidence{HasMore: true}); err == nil { + t.Fatal("continuation without next cursor returned success") + } + }) +} + +func TestCrossPlatformCoverageDINGReceiverExecutionFailuresAndLegacy(t *testing.T) { + t.Run("transport failure", func(t *testing.T) { + caller := &dingCoverageCaller{ + responses: map[string][]string{}, + failures: map[string]error{"list_ding_receiver_status": errors.New("fixture transport failure")}, + } + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("receiver transport failure returned success") + } + }) + + t.Run("projection failure", func(t *testing.T) { + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, caller, "--ding-id", "ding-1"); err == nil { + t.Fatal("empty receiver response returned success") + } + }) + + t.Run("legacy output", func(t *testing.T) { + legacy := ReceiverStatus + legacy.OutputRollout = output.RolloutLegacyOnly + caller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + stdout, err := runDingCoverageOutput(t, legacy, caller, "--ding-id", "ding-1") + if err != nil { + t.Fatalf("receiver legacy output: %v", err) + } + var payload map[string]any + if err := json.Unmarshal(stdout, &payload); err != nil || payload["count"] != float64(1) { + t.Fatalf("receiver legacy payload=%#v decode=%v", payload, err) + } + if len(caller.arguments) != 1 || caller.arguments[0]["openDingId"] != "ding-1" { + t.Fatalf("receiver arguments=%#v", caller.arguments) + } + }) +} + +func TestCrossPlatformCoverageDINGExactReadShortcutsProjectUnifiedData(t *testing.T) { + listCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_messages": {`{"success":true,"result":{"dingMessages":[{"openDingId":"ding-1","dingContent":"fixture"}],"hasMore":false,"nextCursor":null}}`}, + }} + cmd, err := runDingCoverage(t, List, listCaller, "--type", "ALL") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if _, leaked := data["result"]; leaked || data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("list unified projection=%#v", data) + } + if strings.Join(listCaller.history, ",") != "list_ding_messages" { + t.Fatalf("list call history=%v", listCaller.history) + } + + receiverCaller := &dingCoverageCaller{responses: map[string][]string{ + "list_ding_receiver_status": {`{"success":true,"result":{"receivers":[{"openDingId":"ding-1","confirmedStatus":1,"receiverNick":"fixture"}]}}`}, + }} + if _, err := runDingCoverage(t, ReceiverStatus, receiverCaller, "--ding-id", "ding-1"); err != nil { + t.Fatal(err) + } + if strings.Join(receiverCaller.history, ",") != "list_ding_receiver_status" { + t.Fatalf("receiver call history=%v", receiverCaller.history) + } +} + +func TestCrossPlatformCoverageDINGUnavailableWritesNeverCallMCP(t *testing.T) { + args := []string{"--group", "cid-fixture", "--message-id", "mid-fixture", "--users", "D-fixture"} + unconfirmed := &dingCoverageCaller{responses: map[string][]string{}} + err := runDingRoot(t, SendByMessage, unconfirmed, false, args...) + if err == nil || len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed error=%v calls=%v", err, unconfirmed.history) + } + confirmed := &dingCoverageCaller{responses: map[string][]string{}} + err = runDingRoot(t, SendByMessage, confirmed, true, args...) + if err == nil || !strings.Contains(err.Error(), "当前不可执行") || len(confirmed.history) != 0 { + t.Fatalf("confirmed unavailable error=%v calls=%v", err, confirmed.history) + } +} + +func TestCrossPlatformCoverageDINGCompatibilityWritesRequireConfirmationAndExecute(t *testing.T) { + for input, want := range map[string]string{"app": "APP", " sms ": "SMS", "call": "PHONE"} { + got, err := dingPersonalRemindType(input) + if err != nil || got != want { + t.Errorf("dingPersonalRemindType(%q)=(%q,%v), want (%q,nil)", input, got, err, want) + } + } + + sendArgs := []string{"--users", "D-fixture", "--content", "fixture", "--type", "call", "--uuid", "fixture-uuid"} + unconfirmedSend := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, unconfirmedSend, false, sendArgs...); err == nil || len(unconfirmedSend.history) != 0 { + t.Fatalf("unconfirmed send error=%v calls=%v", err, unconfirmedSend.history) + } + confirmedSend := &dingCoverageCaller{responses: map[string][]string{ + "send_personal_ding": {`{"success":true,"result":{"openDingId":"ding-fixture"}}`}, + }} + if err := runDingRoot(t, SendPersonal, confirmedSend, true, sendArgs...); err != nil { + t.Fatalf("confirmed send failed: %v", err) + } + if strings.Join(confirmedSend.history, ",") != "send_personal_ding" || len(confirmedSend.arguments) != 1 { + t.Fatalf("confirmed send calls=%v args=%v", confirmedSend.history, confirmedSend.arguments) + } + sendParams := confirmedSend.arguments[0] + users, _ := sendParams["receiverOpenDingTalkIds"].([]string) + if strings.Join(users, ",") != "D-fixture" || sendParams["content"] != "fixture" || sendParams["remindType"] != "PHONE" || sendParams["uuid"] != "fixture-uuid" { + t.Fatalf("confirmed send params=%#v", sendParams) + } + + invalidType := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, SendPersonal, invalidType, true, "--users", "D-fixture", "--content", "fixture", "--type", "other"); err == nil || len(invalidType.history) != 0 { + t.Fatalf("invalid type error=%v calls=%v", err, invalidType.history) + } + + recallArgs := []string{"--id", "ding-fixture"} + unconfirmedRecall := &dingCoverageCaller{responses: map[string][]string{}} + if err := runDingRoot(t, RecallPersonal, unconfirmedRecall, false, recallArgs...); err == nil || len(unconfirmedRecall.history) != 0 { + t.Fatalf("unconfirmed recall error=%v calls=%v", err, unconfirmedRecall.history) + } + confirmedRecall := &dingCoverageCaller{responses: map[string][]string{ + "recall_personal_ding": {`{"success":true,"result":true}`}, + }} + if err := runDingRoot(t, RecallPersonal, confirmedRecall, true, recallArgs...); err != nil { + t.Fatalf("confirmed recall failed: %v", err) + } + if strings.Join(confirmedRecall.history, ",") != "recall_personal_ding" || confirmedRecall.arguments[0]["openDingId"] != "ding-fixture" { + t.Fatalf("confirmed recall calls=%v args=%v", confirmedRecall.history, confirmedRecall.arguments) + } +} + +func runDingRoot(t *testing.T, declaration shortcut.Shortcut, caller *dingCoverageCaller, yes bool, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "ding"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(declaration))) + root.AddCommand(service) + argv := []string{"ding", declaration.Command} + argv = append(argv, args...) + if yes { + argv = append(argv, "--yes") + } + root.SetArgs(argv) + return root.Execute() +} diff --git a/internal/shortcut/doc/doc.go b/internal/shortcut/doc/doc.go index 494ee29a..0b66fe44 100644 --- a/internal/shortcut/doc/doc.go +++ b/internal/shortcut/doc/doc.go @@ -19,6 +19,7 @@ package doc import ( "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/commentreaction" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/localio" @@ -564,6 +565,9 @@ var CommentReply = shortcut.Shortcut{ "replyCommentKey": rt.Str("comment-key"), } if rt.Bool("emoji") { + if err := commentreaction.Validate(rt.Str("content")); err != nil { + return err + } params["emoji"] = true } if rt.Changed("mention") { diff --git a/internal/shortcut/doc/doc_shortcuts_test.go b/internal/shortcut/doc/doc_shortcuts_test.go index 8b3da279..db3e9441 100644 --- a/internal/shortcut/doc/doc_shortcuts_test.go +++ b/internal/shortcut/doc/doc_shortcuts_test.go @@ -132,6 +132,31 @@ func runDocCoveragePath(t *testing.T, declaration shortcut.Shortcut, caller *doc return root.Execute() } +func TestCrossPlatformCoverageCommentReplyRejectsUnsupportedEmojiBeforeRPC(t *testing.T) { + for _, content := range []string{"😄", "乱码"} { + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + err := runDocCoverage(t, CommentReply, caller, + "--node", "node-1", "--comment-key", "comment-1", + "--content", content, "--emoji", "--yes") + if err == nil { + t.Fatalf("unsupported reaction %q accepted", content) + } + if caller.calls != 0 { + t.Fatalf("unsupported reaction %q reached RPC %d time(s)", content, caller.calls) + } + } + + caller := &docCoverageCaller{responses: map[string][]map[string]any{}} + if err := runDocCoverage(t, CommentReply, caller, + "--node", "node-1", "--comment-key", "comment-1", + "--content", "鼓掌", "--emoji", "--yes"); err != nil { + t.Fatal(err) + } + if caller.calls != 1 || caller.history[0].params["emoji"] != true { + t.Fatalf("valid reaction call = %#v", caller.history) + } +} + func TestCrossPlatformCoverageRevisionSelectionAndKeywordUseLiveShapes(t *testing.T) { revisionPayload := map[string]any{"data": map[string]any{"revision": json.Number("9")}} if got, ok := nestedRevision(revisionPayload); !ok || got != 9 { diff --git a/internal/shortcut/mail/common.go b/internal/shortcut/mail/common.go new file mode 100644 index 00000000..6ac9c5f9 --- /dev/null +++ b/internal/shortcut/mail/common.go @@ -0,0 +1,527 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const mailCompositeReason = "Reviewed Mail Shortcut composite: the executable CLI owns strict response validation, pagination evidence, stable-identity checks, output projection, and confirmation; no single MCP interface represents the complete command contract." + +const mailCompatibilityInterfaceReason = "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data or cleanup proof gap is closed." + +func mailCollectionResult(collection, description string) *contract.ResultSpec { + identity := mailCollectionIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: mailCollectionSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件业务记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection, + )), + } +} + +func mailCollectionSensitivePaths(collection string) []string { + switch collection { + case "threads": + return []string{"threads.subject", "threads.lastUpdated"} + case "folders": + return []string{"folders.name"} + case "tags": + return []string{"tags.name"} + case "users": + return []string{"users.name", "users.email", "users.employeeNo"} + case "templates": + return []string{"templates.name", "templates.subject"} + case "contacts": + return []string{"contacts.contactEmail", "contacts.displayName"} + case "messages": + return []string{"messages.subject", "messages.body", "messages.markdownBody", "messages.from", "messages.toRecipients", "messages.ccRecipients", "messages.bccRecipients", "messages.attachments", "messages.receivedDateTime", "messages.sentDateTime"} + default: + panic("unsupported Mail collection sensitive paths: " + collection) + } +} + +func mailCollectionIdentitySchema(collection string) string { + switch collection { + case "threads": + return `"properties":{"conversationId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["conversationId"]` + case "folders", "tags", "templates", "contacts", "messages": + return `"properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"]` + case "users": + return `"properties":{"userId":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["userId"]},{"required":["email"]}]` + default: + panic("unsupported Mail collection Result identity: " + collection) + } +} + +func mailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func mailObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格校验且身份匹配的邮件业务对象","properties":{"id":{"type":"string","minLength":1,"description":"稳定邮件业务对象 ID"}},"required":["id"],"additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.body", "value.markdownBody", "value.subject", "value.summary", "value.name", "value.email", "value.contactEmail", "value.displayName", "value.from", "value.toRecipients", "value.ccRecipients", "value.bccRecipients", "value.senders", "value.attachments", "value.receivedDateTime", "value.sentDateTime"}, + } +} + +func mailReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func mailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func mailStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) (string, error) { + if !required && !rt.Changed(flag) { + return "", nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func mailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + _, err := mailStringPageSize(rt, flag, required) + return err +} + +func mailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func mailValidateThreadList(rt *shortcut.RuntimeContext) error { + if err := mailValidatePageSize(rt, "limit", true); err != nil { + return err + } + parsed := make(map[string]time.Time, 2) + for _, flag := range []string{"start", "end"} { + if !rt.Changed(flag) { + continue + } + value, err := time.Parse(time.RFC3339, strings.TrimSpace(rt.Str(flag))) + if err != nil { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须是 RFC3339 时间", flag)) + } + _, offset := value.Zone() + if offset != 0 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须使用 UTC 时区", flag)) + } + parsed[flag] = value + } + start, hasStart := parsed["start"] + end, hasEnd := parsed["end"] + if hasStart && hasEnd && end.Before(start) { + return apperrors.NewValidation("--end 不能早于 --start") + } + return nil +} + +func mailWriteSafety(idempotency string) contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "medium", Confirmation: "user_required", Idempotency: idempotency} +} + +func mailReadContract(command, description, intent string, result *contract.ResultSpec, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "mail " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: name, CanonicalPath: "mail." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需摘要列表时使用 mail +triage 或 +recent-mail;需要原始响应时改用对应原子命令"}, + Examples: examples, + }, + Parameters: flags, + } +} + +func mailWriteContract(command, description, intent string, flags []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + contractDecl := mailReadContract(command, description, intent, mailObjectResult(description), flags, examples...) + contractDecl.Selection.AvoidWhen = []string{"用户尚未确认写入内容或目标时不要执行;只需读取时使用对应只读 shortcut"} + return contractDecl +} + +func mailResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// mailRequireSuccess accepts only the two success encodings observed from the +// Mail backend: boolean true and the exact string "true". Missing, false, or +// malformed status values fail closed. +func mailRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return mailResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return mailResponseError(operation, "missing_success", "响应缺少 success 业务状态") + } + message := mailFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务未明确返回成功状态" + } + return mailResponseError(operation, "remote_failure", message) +} + +func mailRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present { + return nil, mailResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func mailRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, path) + if !present || value == nil { + return nil, mailResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, mailResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func mailRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := mailFirstString(object, keys...) + if actual == "" { + return mailResponseError(operation, "missing_stable_id", "业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return mailResponseError(operation, "identity_mismatch", "业务对象 ID 与请求目标不一致") + } + return nil +} + +func mailValidateRows(items []map[string]any, operation string, identityKeys ...string) error { + allowNumericID := false + for _, key := range identityKeys { + if key == "email" { + allowNumericID = true + break + } + } + for index, item := range items { + valid := mailFirstString(item, identityKeys...) != "" + if !valid && allowNumericID { + valid = mailNumericIdentity(item["id"]) + } + if !valid { + return mailResponseError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return nil +} + +func mailNumericIdentity(value any) bool { + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64, json.Number: + return true + default: + return false + } +} + +func mailProjectValue(operation, outputName string, value any) (any, bool, error) { + if value == nil { + return nil, false, nil + } + switch outputName { + case "email": + text, ok := value.(string) + if !ok { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + return text, text != "", nil + case "userId": + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + return text, text != "", nil + } + if !mailNumericIdentity(value) { + return nil, false, mailResponseError(operation, "malformed_item_identity", "邮箱用户 userId 必须是字符串或数字") + } + return value, true, nil + default: + if text, ok := value.(string); ok { + if strings.TrimSpace(text) == "" { + return nil, false, nil + } + return text, true, nil + } + return value, true, nil + } +} + +func mailProjectCollection(data map[string]any, operation, path string, identityKeys []string, fields map[string][]string) ([]map[string]any, error) { + items, err := mailRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + if err := mailValidateRows(items, operation, identityKeys...); err != nil { + return nil, err + } + projected := make([]map[string]any, 0, len(items)) + for _, item := range items { + row := make(map[string]any, len(fields)) + for outputName, candidates := range fields { + for _, candidate := range candidates { + if value, present := item[candidate]; present && value != nil { + projectedValue, include, err := mailProjectValue(operation, outputName, value) + if err != nil { + return nil, err + } + if !include { + continue + } + row[outputName] = projectedValue + break + } + } + } + projected = append(projected, row) + } + return projected, nil +} + +func mailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := mailLookup(data, prefix) + if !present { + return false, "", mailResponseError(operation, "missing_pagination", "分页响应缺少已审核的分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "分页容器不是对象") + } + } + nextValue, nextPresent := container["nextCursor"] + next, nextOK := nextValue.(string) + if !nextPresent || !nextOK { + return false, "", mailResponseError(operation, "missing_pagination", "响应缺少字符串 nextCursor,无法证明结果是否完整") + } + if raw, present := container["hasMore"]; present { + hasMore, ok := mailBool(raw) + if !ok { + return false, "", mailResponseError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore && terminal { + return false, "", mailResponseError(operation, "missing_next_cursor", "hasMore=true 但 nextCursor 为空") + } + if !hasMore && !terminal { + return false, "", mailResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", mailResponseError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func mailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func mailLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func mailFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func mailCollectionPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func mailBusinessCollectionPayload(collection string, items []map[string]any) map[string]any { + return map[string]any{"count": len(items), collection: items} +} + +// mailOutputPage preserves legacy payload compatibility while keeping cursor +// controls in the unified envelope's meta.pagination contract. +func mailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := mailCollectionPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return mailResponseError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} + +func hardenPublicMailContracts() { + collections := []struct { + declaration *shortcut.Shortcut + collection string + description string + paginated bool + }{ + {&ThreadList, "threads", "严格校验的邮件会话列表", true}, + {&FolderList, "folders", "严格校验的邮箱文件夹列表", false}, + {&TagList, "tags", "严格校验的邮件标签列表", false}, + {&UserSearch, "users", "严格校验的企业邮箱用户搜索结果", true}, + {&TemplateList, "templates", "严格校验的邮件模板列表", true}, + {&ContactList, "contacts", "严格校验的邮件联系人列表", true}, + } + for _, item := range collections { + item.declaration.OutputRollout = output.RolloutUnifiedActive + item.declaration.Contract.Result = mailCollectionResult(item.collection, item.description) + if item.paginated { + item.declaration.Contract.Pagination = mailCursorPagination() + } + item.declaration.Contract.Interface = &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: mailCompositeReason} + } + markMailCompatibilityOnly(&ThreadList) + markMailCompatibilityOnly(&TagList) + markMailCompatibilityOnly(&TemplateList) + markMailCompatibilityOnly(&ContactList) +} + +func markMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: mailCompatibilityInterfaceReason, + } +} + +func markMailUnavailable(declaration *shortcut.Shortcut, reason string) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceUnavailable, + Reason: reason, + } +} diff --git a/internal/shortcut/mail/mail.go b/internal/shortcut/mail/mail.go index 525505ce..2aeab198 100644 --- a/internal/shortcut/mail/mail.go +++ b/internal/shortcut/mail/mail.go @@ -18,8 +18,11 @@ package mail import ( + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "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" ) @@ -64,6 +67,13 @@ var ThreadList = shortcut.Shortcut{ PrimaryCLIPath: "mail +thread-list", }, Description: "列出指定邮箱文件夹下的邮件会话(thread)", + Parameters: []contract.ParamDecl{ + {Name: "folder", Property: "folder"}, + {Name: "limit", Property: "limit"}, + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "ascending", Property: "ascending"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -85,6 +95,13 @@ var ThreadList = shortcut.Shortcut{ {Name: "end", Type: shortcut.FlagString, Desc: "结束 UTC 时间,如 2024-12-31T23:59:59Z"}, {Name: "ascending", Type: shortcut.FlagBool, Desc: "是否按时间升序"}, }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start/--end 必须是 UTC RFC3339 时间,且 end 不能早于 start"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateThreadList(rt) + }, Tips: []string{ `dws mail +thread-list --email user@company.com --folder 104 --limit 20`, }, @@ -110,76 +127,20 @@ var ThreadList = shortcut.Shortcut{ if err != nil { return err } - threads := threadListProject(data) - return rt.Output(map[string]any{"count": len(threads), "threads": threads}) + threads, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "threads", threads, complete, next) }, } -// threadListProject reshapes the raw list_mailbox_threads response into a clean -// {conversationId, subject, lastUpdated, isRead} thread list — -// clean output projection. Both the list container and per-item -// field names are probed defensively across candidate keys, so an empty/unknown -// shape yields an empty list rather than a crash or fabricated data. -func threadListProject(data map[string]any) []map[string]any { - raw := threadListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := threadListFirst(m, "conversationId", "conversation_id", "id", "threadId"); ok { - row["conversationId"] = v - } - if v, ok := threadListFirst(m, "subject", "title", "topic"); ok { - row["subject"] = v - } - if v, ok := threadListFirst(m, "lastUpdated", "last_updated", "lastModifiedDateTime", "updateTime", "modifiedTime", "sentDateTime"); ok { - row["lastUpdated"] = v - } - if v, ok := threadListFirst(m, "isRead", "is_read", "read", "unread"); ok { - row["isRead"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// threadListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func threadListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "threads", "conversations"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "threads", "conversations", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// threadListFirst returns the first present candidate key's value. -func threadListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // Thread 根据会话 ID 获取会话详情。 // ThreadUpdate 修改单个邮件会话的状态或标签。 // ThreadBatchUpdate 批量修改邮件会话的状态或标签。 @@ -208,6 +169,7 @@ var FolderList = shortcut.Shortcut{ PrimaryCLIPath: "mail +folder-list", }, Description: "列出顶层文件夹或指定父文件夹下的子文件夹", + Parameters: []contract.ParamDecl{{Name: "folder", Property: "folder"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -221,9 +183,13 @@ var FolderList = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址", Required: true}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮件所属邮箱地址,不能为空", Required: true}, {Name: "folder", Type: shortcut.FlagString, Desc: "父文件夹 ID,不传则返回顶层文件夹"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"email"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "email") + }, Tips: []string{ `dws mail +folder-list --email user@company.com`, }, @@ -238,73 +204,16 @@ var FolderList = shortcut.Shortcut{ if err != nil { return err } - folders := folderListProject(data) - return rt.Output(map[string]any{"count": len(folders), "folders": folders}) + folders, err := mailProjectCollection(data, "mail/list_folders", "folders", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"displayName"}, "parentId": {"parentFolderId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("folders", folders)) }, } -// folderListProject reshapes the raw list_folders response into a clean -// {id, name, parentId} folder list — clean output projection. Both -// the list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func folderListProject(data map[string]any) []map[string]any { - raw := folderListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := folderListFirst(m, "id", "folderId", "folder_id"); ok { - row["id"] = v - } - if v, ok := folderListFirst(m, "name", "folderName", "folder_name", "displayName"); ok { - row["name"] = v - } - if v, ok := folderListFirst(m, "parentId", "parent_id", "parentFolderId"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// folderListResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func folderListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "folders"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "folders", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// folderListFirst returns the first present candidate key's value. -func folderListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // FolderCreate 创建邮件文件夹。 // FolderUpdate 更新邮件文件夹名称。 // FolderDelete 删除邮件文件夹。 @@ -356,73 +265,16 @@ var TagList = shortcut.Shortcut{ if err != nil { return err } - tags := tagListProject(data) - return rt.Output(map[string]any{"count": len(tags), "tags": tags}) + tags, err := mailProjectCollection(data, "mail/list_tags", "tags", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "parentId": {"parentId"}, + }) + if err != nil { + return err + } + return rt.Output(mailBusinessCollectionPayload("tags", tags)) }, } -// tagListProject reshapes the raw list_tags response into a clean -// {id, name, parentId} tag list — clean output projection. Both the -// list container and per-item field names are probed defensively across -// candidate keys, so an empty/unknown shape yields an empty list rather than a -// crash or fabricated data. -func tagListProject(data map[string]any) []map[string]any { - raw := tagListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := tagListFirst(m, "id", "tagId", "tag_id"); ok { - row["id"] = v - } - if v, ok := tagListFirst(m, "name", "tagName", "tag_name", "displayName"); ok { - row["name"] = v - } - if v, ok := tagListFirst(m, "parentId", "parent_id"); ok { - row["parentId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// tagListResolveList locates the list payload inside the response, tolerating a -// bare top-level array container or nesting one level deeper. -func tagListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "tags"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "tags", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// tagListFirst returns the first present candidate key's value. -func tagListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TagCreate 创建邮件标签。 // TagUpdate 更新邮件标签名称。 // TagDelete 删除邮件标签。 @@ -449,6 +301,10 @@ var UserSearch = shortcut.Shortcut{ PrimaryCLIPath: "mail +user-search", }, Description: "按关键词或工号搜索邮箱用户(仅企业邮箱)", + Parameters: []contract.ParamDecl{ + {Name: "employee-no", Property: "employeeNo"}, + {Name: "limit", Property: "limit"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -465,20 +321,40 @@ var UserSearch = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词(未提供 --employee-no 时为必填)"}, - {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索"}, + {Name: "keyword", Type: shortcut.FlagString, Desc: "搜索关键词;显式提供时不能为空(未提供 --employee-no 时为必填)"}, + {Name: "employee-no", Type: shortcut.FlagString, Desc: "按工号精确搜索;显式提供时不能为空"}, {Name: "email", Type: shortcut.FlagString, Desc: "搜索目标邮箱地址"}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量"}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数"}, }, Constraints: []shortcut.Constraint{ {Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"keyword", "employee-no"}}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"keyword", "employee-no"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := mailValidateStringPageSize(rt, "limit", false); err != nil { + return err + } + for _, name := range []string{"keyword", "employee-no"} { + if rt.Changed(name) && strings.TrimSpace(rt.Str(name)) == "" { + return apperrors.NewValidation("--" + name + " 显式提供时不能为空") + } + } + if strings.TrimSpace(rt.Str("keyword")) == "" && strings.TrimSpace(rt.Str("employee-no")) == "" { + return apperrors.NewValidation("--keyword 和 --employee-no 至少需要一个非空值") + } + return nil }, Tips: []string{ `dws mail +user-search --keyword "张三"`, `dws mail +user-search --email user@company.com --employee-no "E123456"`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", false) + if err != nil { + return err + } params := map[string]any{} if rt.Str("keyword") != "" { params["keyword"] = rt.Str("keyword") @@ -493,82 +369,26 @@ var UserSearch = shortcut.Shortcut{ params["cursor"] = rt.Str("cursor") } if rt.Changed("limit") { - params["size"] = rt.Str("limit") + params["size"] = size } data, err := rt.CallMCPData("mail", "search_mail_users", params) if err != nil { return err } - users := userSearchProject(data) - return rt.Output(map[string]any{"count": len(users), "users": users}) + users, err := mailProjectCollection(data, "mail/search_mail_users", "users", []string{"id", "email"}, map[string][]string{ + "name": {"name"}, "email": {"email"}, "employeeNo": {"employeeNo"}, "userId": {"id"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "users", users, complete, next) }, } -// userSearchProject reshapes the raw search_mail_users response into a clean -// {name, email, employeeNo, userId} user list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func userSearchProject(data map[string]any) []map[string]any { - raw := userSearchResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := userSearchFirst(m, "name", "userName", "displayName", "nickName"); ok { - row["name"] = v - } - if v, ok := userSearchFirst(m, "email", "mail", "emailAddress"); ok { - row["email"] = v - } - if v, ok := userSearchFirst(m, "employeeNo", "employee_no", "employeeNumber", "jobNumber"); ok { - row["employeeNo"] = v - } - if v, ok := userSearchFirst(m, "userId", "user_id", "id"); ok { - row["userId"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// userSearchResolveList locates the list payload inside the response, tolerating -// a bare top-level array container or nesting one level deeper. -func userSearchResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "users"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "users", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// userSearchFirst returns the first present candidate key's value. -func userSearchFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ── attachment ───────────────────────────────────────────── // AttachmentList 列举邮件附件。 @@ -596,6 +416,7 @@ var TemplateList = shortcut.Shortcut{ PrimaryCLIPath: "mail +template-list", }, Description: "列出指定邮箱的所有邮件模板", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -610,16 +431,24 @@ var TemplateList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +template-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -628,73 +457,20 @@ var TemplateList = shortcut.Shortcut{ if err != nil { return err } - templates := templateListProject(data) - return rt.Output(map[string]any{"count": len(templates), "templates": templates}) + templates, err := mailProjectCollection(data, "mail/list_user_message_templates", "templates", []string{"id"}, map[string][]string{ + "id": {"id"}, "name": {"name"}, "subject": {"subject"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_message_templates", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "templates", templates, complete, next) }, } -// templateListProject reshapes the raw list_user_message_templates response into -// a clean {id, name, subject} template list — clean output projection. -// Both the list container and per-item field names are probed defensively -// across candidate keys, so an empty/unknown shape yields an empty list rather -// than a crash or fabricated data. -func templateListProject(data map[string]any) []map[string]any { - raw := templateListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := templateListFirst(m, "id", "templateId", "template_id"); ok { - row["id"] = v - } - if v, ok := templateListFirst(m, "name", "templateName", "template_name", "displayName"); ok { - row["name"] = v - } - if v, ok := templateListFirst(m, "subject", "title"); ok { - row["subject"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// templateListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func templateListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "templates"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "templates", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// templateListFirst returns the first present candidate key's value. -func templateListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // TemplateGet 获取邮件模板详情。 // TemplateUpdate 更新邮件模板。 // TemplateDelete 删除邮件模板。 @@ -722,6 +498,7 @@ var ContactList = shortcut.Shortcut{ PrimaryCLIPath: "mail +contact-list", }, Description: "列出指定邮箱的所有邮件联系人", + Parameters: []contract.ParamDecl{{Name: "limit", Property: "limit"}}, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -736,16 +513,24 @@ var ContactList = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "用户邮箱地址", Required: true}, - {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量", Required: true}, + {Name: "limit", Type: shortcut.FlagString, Desc: "每页返回数量,必须是 1-100 之间的整数", Required: true}, {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自响应中的 nextCursor"}, }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateStringPageSize(rt, "limit", true) + }, Tips: []string{ `dws mail +contact-list --email user@company.com --limit 20`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := mailStringPageSize(rt, "limit", true) + if err != nil { + return err + } params := map[string]any{ "email": rt.Str("email"), - "size": rt.Str("limit"), + "size": size, } if rt.Changed("cursor") { params["cursor"] = rt.Str("cursor") @@ -754,73 +539,20 @@ var ContactList = shortcut.Shortcut{ if err != nil { return err } - contacts := contactListProject(data) - return rt.Output(map[string]any{"count": len(contacts), "contacts": contacts}) + contacts, err := mailProjectCollection(data, "mail/list_user_mail_contacts", "contacts", []string{"id"}, map[string][]string{ + "id": {"id"}, "contactEmail": {"contactEmail", "email"}, "displayName": {"displayName", "name"}, + }) + if err != nil { + return err + } + complete, next, err := mailPage(data, "mail/list_user_mail_contacts", "", rt.Str("cursor")) + if err != nil { + return err + } + return mailOutputPage(rt, "contacts", contacts, complete, next) }, } -// contactListProject reshapes the raw list_user_mail_contacts response into a -// clean {id, contactEmail, displayName} contact list — output-projection -// clean output projection. Both the list container and per-item field names are probed -// defensively across candidate keys, so an empty/unknown shape yields an empty -// list rather than a crash or fabricated data. -func contactListProject(data map[string]any) []map[string]any { - raw := contactListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := contactListFirst(m, "id", "contactId", "contact_id"); ok { - row["id"] = v - } - if v, ok := contactListFirst(m, "contactEmail", "contact_email", "email"); ok { - row["contactEmail"] = v - } - if v, ok := contactListFirst(m, "displayName", "display_name", "name"); ok { - row["displayName"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// contactListResolveList locates the list payload inside the response, -// tolerating a bare top-level array container or nesting one level deeper. -func contactListResolveList(data map[string]any) []any { - for _, key := range []string{"result", "data", "list", "items", "contacts"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "contacts", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// contactListFirst returns the first present candidate key's value. -func contactListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - // ContactUpdate 更新邮件联系人。 // ContactBatchDelete 批量删除邮件联系人。 // ── auto-reply ───────────────────────────────────────────── @@ -834,6 +566,7 @@ func contactListFirst(m map[string]any, keys ...string) (any, bool) { // RuleDelete 删除个人收信规则。 // RuleAdjust 调整收信规则排序。 func init() { + hardenPublicMailContracts() shortcut.Register( ThreadList, FolderList, diff --git a/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..248ecd6c --- /dev/null +++ b/internal/shortcut/mail/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,422 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func runMailDeclarationForCoverage(t *testing.T, declaration shortcut.Shortcut, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func mailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageMailReadLeavesExerciseExactIdentityAndMailboxBranches(t *testing.T) { + for _, tc := range []struct { + name string + args []string + responses map[string][]string + errors map[string]error + wantErr bool + wantCalls int + }{ + {name: "message explicit mailbox", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 1}, + {name: "message auto mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "message auto nested string mailbox", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "mailbox transport", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing collection", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox empty", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "mailbox missing identity", args: []string{"mail", "+message", "--id", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"emailAccounts":[{"name":"mailbox"}]}`}}, wantErr: true, wantCalls: 1}, + {name: "message transport", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("message transport")}, wantErr: true, wantCalls: 1}, + {name: "message malformed", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":[]}`}}, wantErr: true, wantCalls: 1}, + {name: "message wrong identity", args: []string{"mail", "+message", "--email", "mail@example.invalid", "--id", "message-1"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + {name: "messages ordered", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"message-2"}}`}}, wantCalls: 2}, + {name: "messages auto data mailbox", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"data":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`}}, wantCalls: 2}, + {name: "messages second fails", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1,message-2"}, responses: map[string][]string{"get_email_by_message_id": {`{"success":true,"message":{"id":"message-1"}}`, `{"success":true,"message":{"id":"other"}}`}}, wantErr: true, wantCalls: 2}, + {name: "messages mailbox resolution fails", args: []string{"mail", "+messages", "--ids", "message-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread exact", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 1}, + {name: "thread auto result mailbox", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{"list_user_mailboxes": {`{"success":true,"result":{"emailAccounts":[{"email":"mail@example.invalid"}]}}`}, "get_thread": {`{"success":true,"conversation":{"id":"thread-1"}}`}}, wantCalls: 2}, + {name: "thread transport", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"get_thread": fmt.Errorf("thread transport")}, wantErr: true, wantCalls: 1}, + {name: "thread mailbox resolution fails", args: []string{"mail", "+thread", "--id", "thread-1"}, responses: map[string][]string{}, errors: map[string]error{"list_user_mailboxes": fmt.Errorf("mailbox transport")}, wantErr: true, wantCalls: 1}, + {name: "thread missing result", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true}`}}, wantErr: true, wantCalls: 1}, + {name: "thread wrong identity", args: []string{"mail", "+thread", "--email", "mail@example.invalid", "--id", "thread-1"}, responses: map[string][]string{"get_thread": {`{"success":true,"conversation":{"id":"other"}}`}}, wantErr: true, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: tc.errors} + if caller.responses == nil { + caller.responses = map[string][]string{} + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid read unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid read failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + if err := mailValidateMessageIDs(nil); err == nil { + t.Fatal("empty ids unexpectedly valid") + } + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, Messages, map[string]string{"email": "mail@example.invalid", "ids": " "}) + if err := Messages.Execute(rt); err == nil { + t.Fatal("defensive empty-id guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("defensive empty-id guard made %d calls", len(caller.calls)) + } +} + +func TestCrossPlatformCoverageMailMailboxResolverSupportsReviewedShapesAndFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + { + name: "top string", + data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, + }, + { + name: "top object", + data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, + }, + { + name: "result string", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "result object", + data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + { + name: "data string", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, + }, + { + name: "data object", + data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, + }, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := mailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != "mail@example.invalid" { + t.Fatalf("addresses=%#v err=%v", got, err) + } + }) + } + + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing collection": {"success": true}, + "malformed collection": {"success": true, "emailAccounts": map[string]any{}}, + "blank string": {"success": true, "emailAccounts": []any{" "}}, + "missing object email": {"success": true, "emailAccounts": []any{map[string]any{"name": "mailbox"}}}, + "wrong object email": {"success": true, "emailAccounts": []any{map[string]any{"email": 7}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "result": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } +} + +func TestCrossPlatformCoverageMailListLeavesFailAtOwningBoundary(t *testing.T) { + for _, tc := range []struct { + name string + args []string + tool string + response string + remoteErr bool + wantErr bool + }{ + {name: "folder happy", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid", "--folder", "parent-1"}, tool: "list_folders", response: `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`}, + {name: "folder remote", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", remoteErr: true, wantErr: true}, + {name: "folder malformed", args: []string{"mail", "+folder-list", "--email", "mail@example.invalid"}, tool: "list_folders", response: `{"success":true,"folders":{}}`, wantErr: true}, + {name: "tag happy", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":[{"id":"tag-1"}]}`}, + {name: "tag remote", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", remoteErr: true, wantErr: true}, + {name: "tag malformed", args: []string{"mail", "+tag-list", "--email", "mail@example.invalid"}, tool: "list_tags", response: `{"success":true,"tags":{}}`, wantErr: true}, + {name: "thread malformed rows", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":{},"nextCursor":""}}`, wantErr: true}, + {name: "thread missing page", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}]}}`, wantErr: true}, + {name: "user malformed rows", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":{}}`, wantErr: true}, + {name: "user missing page", args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1"}]}`, wantErr: true}, + {name: "template malformed rows", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":{}}`, wantErr: true}, + {name: "template missing page", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}]}`, wantErr: true}, + {name: "contact malformed rows", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":{}}`, wantErr: true}, + {name: "contact missing page", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}]}`, wantErr: true}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if tc.remoteErr { + caller.errors[tc.tool] = fmt.Errorf("remote failure") + } + err := runConfirmedMailWriteContract(t, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid list response unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid list response failed: %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageMailStrictHelpersCoverMalformedWireAndPagination(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + deferred := false + defer func() { + if recover() == nil { + t.Fatal("unsupported declaration did not panic") + } + deferred = true + }() + fn() + _ = deferred + }) + } + assertPanic("sensitive collection", func() { mailCollectionSensitivePaths("unknown") }) + assertPanic("identity collection", func() { mailCollectionIdentitySchema("unknown") }) + + if _, err := mailRequireObject(map[string]any{}, "mail/get", "message"); err == nil { + t.Fatal("object accepted missing success") + } + if object, err := mailRequireObject(map[string]any{"success": true, "message": map[string]any{"id": "message-1"}}, "mail/get", "message"); err != nil || object["id"] != "message-1" { + t.Fatalf("valid object rejected: %#v %v", object, err) + } + if _, _, err := mailProjectValue("mail/users", "email", 7); err == nil { + t.Fatal("numeric email unexpectedly projected") + } + if _, include, err := mailProjectValue("mail/users", "userId", " "); err != nil || include { + t.Fatalf("blank user id include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", nil); err != nil || include { + t.Fatalf("nil projection include=%v err=%v", include, err) + } + if _, include, err := mailProjectValue("mail/test", "name", " "); err != nil || include { + t.Fatalf("blank projection include=%v err=%v", include, err) + } + if value, include, err := mailProjectValue("mail/test", "rank", 7); err != nil || !include || value != 7 { + t.Fatalf("numeric projection value=%v include=%v err=%v", value, include, err) + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "malformed cursor": {"nextCursor": 7}, + "malformed hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting terminal": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := mailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := mailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing cursor rejected: %v %q %v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := mailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if _, ok := mailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } + if _, ok := mailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object intermediate lookup unexpectedly succeeded") + } + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageMailWriteVerificationHelpersAndDryRun(t *testing.T) { + for name, message := range map[string]map[string]any{ + "missing addresses": {}, + "wrong address collection": {"toRecipients": "bad"}, + "wrong address item": {"toRecipients": []any{7}}, + "blank address item": {"toRecipients": []any{map[string]any{"email": " "}}}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailReadAddresses(message, "mail/verify", "toRecipients"); err == nil { + t.Fatal("malformed recipients unexpectedly succeeded") + } + }) + } + addresses, err := mailReadAddresses(map[string]any{"toRecipients": []any{" A@example.invalid ", map[string]any{"address": "b@example.invalid"}}}, "mail/verify", "toRecipients") + if err != nil || len(addresses) != 2 { + t.Fatalf("valid recipients rejected: %v %v", addresses, err) + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"a@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid", "b@example.invalid"}); err == nil { + t.Fatal("recipient length mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{"toRecipients": []any{"b@example.invalid"}}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient value mismatch accepted") + } + if err := mailVerifyAddresses(map[string]any{}, "mail/verify", "toRecipients", []string{"a@example.invalid"}); err == nil { + t.Fatal("recipient parser error was not propagated") + } + if _, err := mailDraftMessage(map[string]any{"success": false}, "mail/create"); err == nil { + t.Fatal("failed draft receipt unexpectedly succeeded") + } + for name, message := range map[string]map[string]any{ + "missing sender": {}, + "wrong sender": {"from": []any{}}, + "blank sender": {"from": " "}, + } { + t.Run(name, func(t *testing.T) { + if err := mailVerifySender(message, "mail/verify", "sender@example.invalid"); err == nil { + t.Fatal("malformed sender unexpectedly accepted") + } + }) + } + if err := mailVerifySender(map[string]any{"from": "sender@example.invalid"}, "mail/verify", "SENDER@example.invalid"); err != nil { + t.Fatalf("case-insensitive sender rejected: %v", err) + } + + for _, args := range [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "subject", "--dry-run"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject", "--dry-run"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--dry-run"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject", "--dry-run"}, + } { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, args...); err != nil { + t.Fatalf("dry-run failed: %v", err) + } + if len(caller.calls) != 0 { + t.Fatalf("dry-run made %d remote calls", len(caller.calls)) + } + } + + for _, declaration := range []shortcut.Shortcut{DraftEdit, TemplateUpdate} { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "sender@example.invalid", "id": "template-1"} + if declaration.Command == "+draft-edit" { + values = map[string]string{"from": "sender@example.invalid", "id": "draft-1"} + } + rt := mailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatal("execute-level no-change guard unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("no-change guard made %d calls", len(caller.calls)) + } + } + + for _, tc := range []struct { + name string + readback string + }{ + {name: "body mismatch", readback: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "sender mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid")}, + {name: "cc mismatch", readback: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid")}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {tc.readback}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("draft verification mismatch unexpectedly succeeded") + } + }) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":"false"}`}, + }, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"); err == nil { + t.Fatal("failed template readback unexpectedly succeeded") + } + + for _, tc := range []struct { + declaration shortcut.Shortcut + values map[string]string + }{ + {declaration: Message, values: map[string]string{"id": " "}}, + {declaration: FolderList, values: map[string]string{"email": " "}}, + } { + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Validate(rt); err == nil { + t.Fatal("direct required-text validator accepted whitespace") + } + } + rt := mailRuntimeForCoverage(t, UserSearch, map[string]string{}) + if err := UserSearch.Validate(rt); err == nil { + t.Fatal("direct user-search validator accepted missing identities") + } +} + +func TestCrossPlatformCoverageMailTemplateReadbackRejectsBusinessFailure(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{ + "get_user_message_template": {`{"id":"template-1"}`}, + }, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, TemplateCreate, map[string]string{}) + if _, err := mailReadTemplate(rt, "mail@example.invalid", "template-1"); err == nil { + t.Fatal("failed template detail response unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d want=1", len(caller.calls)) + } +} diff --git a/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go new file mode 100644 index 00000000..99b7ea65 --- /dev/null +++ b/internal/shortcut/mail/mail_confirmation_cross_platform_coverage_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "io" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "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 mailConfirmationCaller struct{ calls int } + +func (caller *mailConfirmationCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) { + caller.calls++ + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: `{"success":true}`}}}, nil +} + +func (*mailConfirmationCaller) Format() string { return "json" } +func (*mailConfirmationCaller) DryRun() bool { return false } +func (*mailConfirmationCaller) Fields() string { return "" } +func (*mailConfirmationCaller) JQ() string { return "" } + +func runUnconfirmedMailWrite(t *testing.T, caller *mailConfirmationCaller, args ...string) error { + t.Helper() + helpers.InitDeps(caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(args) + return root.Execute() +} + +func TestCrossPlatformCoverageMailConfirmationPrecedesRemoteCall(t *testing.T) { + cases := [][]string{ + {"mail", "+draft-create", "--from", "sender@example.invalid", "--subject", "fixture"}, + {"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-id", "--subject", "fixture"}, + {"mail", "+template-create", "--email", "sender@example.invalid", "--name", "fixture", "--subject", "fixture", "--body", "fixture"}, + {"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-id", "--subject", "fixture"}, + } + for _, args := range cases { + caller := &mailConfirmationCaller{} + if err := runUnconfirmedMailWrite(t, caller, args...); err == nil { + t.Fatalf("unconfirmed %v unexpectedly succeeded", args) + } + if caller.calls != 0 { + t.Fatalf("unconfirmed %v reached MCP %d times", args, caller.calls) + } + } +} diff --git a/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..4134fc99 --- /dev/null +++ b/internal/shortcut/mail/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,310 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func TestCrossPlatformCoverageMailContractsAreUnifiedAndTyped(t *testing.T) { + declarations := []*struct { + rollout output.RolloutState + result bool + }{ + {FolderList.OutputRollout, FolderList.Contract.Result != nil}, + {UserSearch.OutputRollout, UserSearch.Contract.Result != nil}, + {Message.OutputRollout, Message.Contract.Result != nil}, + {Messages.OutputRollout, Messages.Contract.Result != nil}, + {Thread.OutputRollout, Thread.Contract.Result != nil}, + } + for index, declaration := range declarations { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("mail declaration %d is not unified with Result", index) + } + } +} + +func TestCrossPlatformCoverageMailUnavailableWritesMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&DraftCreate, &DraftEdit, &TemplateCreate, &TemplateUpdate} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "unavailable" || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing precise unavailable interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &TagList, &TemplateList, &ContactList} { + if declaration.OutputRollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout=%q, want legacy_only", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable contract still publishes result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason != mailCompatibilityInterfaceReason { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailMutationConstraintsAreDeclared(t *testing.T) { + want := map[string]bool{"to": false, "cc": false, "subject": false, "body": false} + for _, constraint := range DraftEdit.Constraints { + if constraint.Kind != shortcut.ConstraintAtLeastOne { + continue + } + for _, flag := range constraint.Flags { + if _, ok := want[flag]; ok { + want[flag] = true + } + } + } + for flag, found := range want { + if !found { + t.Fatalf("draft edit missing at-least-one declaration for %s", flag) + } + } + if len(Messages.Constraints) != 1 || Messages.Constraints[0].Kind != shortcut.ConstraintCustom || Messages.Validate == nil { + t.Fatal("messages must declare and execute the 1-100 nonempty ID contract") + } + userSearchNonempty := false + for _, constraint := range UserSearch.Constraints { + if constraint.Kind == shortcut.ConstraintCustom && len(constraint.Flags) == 2 && constraint.Flags[0] == "keyword" && constraint.Flags[1] == "employee-no" && constraint.Description != "" { + userSearchNonempty = true + } + } + if !userSearchNonempty { + t.Fatal("user search must declare its explicit nonempty string contract") + } +} + +func TestCrossPlatformCoverageMailPaginationDeclarationsMatchLeaves(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UserSearch} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor pagination declaration", declaration.Command) + } + } + for _, declaration := range []*shortcut.Shortcut{&ThreadList, &FolderList, &TagList, &TemplateList, &ContactList, &Messages} { + if declaration.Contract.Pagination != nil { + t.Fatalf("%s must not declare pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailUnifiedPaginationLivesOnlyInMeta(t *testing.T) { + for _, tc := range []struct { + name string + complete bool + next string + exhausted bool + }{ + {name: "continuing", complete: false, next: "cursor-2", exhausted: false}, + {name: "terminal", complete: true, next: "", exhausted: true}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := UserSearch + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := mailOutputPage(rt, "users", []map[string]any{{"email": "fixture@example.invalid"}}, tc.complete, tc.next); err != nil { + t.Fatalf("store pagination: %v", err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatalf("decode output: %v", err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted != tc.exhausted || envelope.Meta.Pagination.NextToken != tc.next { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + }) + } +} + +func TestCrossPlatformCoverageMailResultSchemasRequireStableIdentity(t *testing.T) { + collections := []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&FolderList, "folders", "id"}, + {&UserSearch, "users", "email"}, + {&Messages, "messages", "id"}, + } + for _, tc := range collections { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + count := properties["count"].(map[string]any) + if count["minimum"] != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, count["minimum"]) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity, ok := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if !ok || identity["description"] == "" { + t.Fatalf("%s missing identity %s", tc.declaration.Command, tc.identity) + } + } + for _, declaration := range []*shortcut.Shortcut{&Message, &Thread} { + if len(declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", declaration.Command, err) + } + value := schema["properties"].(map[string]any)["value"].(map[string]any) + required := value["required"].([]any) + if len(required) != 1 || required[0] != "id" { + t.Fatalf("%s value does not require id", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageMailWritesRequireConfirmation(t *testing.T) { + for _, declaration := range []struct { + name string + confirmation string + }{ + {DraftCreate.Command, DraftCreate.Safety.Confirmation}, + {DraftEdit.Command, DraftEdit.Safety.Confirmation}, + {TemplateCreate.Command, TemplateCreate.Safety.Confirmation}, + {TemplateUpdate.Command, TemplateUpdate.Safety.Confirmation}, + } { + if declaration.confirmation != "user_required" { + t.Errorf("%s confirmation=%q, want user_required", declaration.name, declaration.confirmation) + } + } +} + +func TestCrossPlatformCoverageMailStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "folders": []any{}} + items, err := mailRequireCollection(validEmpty, "mail/list_folders", "folders") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty collection must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "missing success": {"folders": []any{}}, + "missing collection": {"success": "true"}, + "wrong collection": {"success": true, "folders": map[string]any{}}, + "bad item": {"success": "true", "folders": []any{"bad"}}, + } { + if _, err := mailRequireCollection(fixture, "mail/list_folders", "folders"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } + if err := mailRequireSuccess(map[string]any{"success": "false"}, "mail/test"); err == nil { + t.Fatal("string false must not be success") + } + if err := mailRequireSuccess(map[string]any{"success": true}, "mail/test"); err != nil { + t.Fatalf("boolean true must be accepted: %v", err) + } +} + +func TestCrossPlatformCoverageMailPaginationAndIdentity(t *testing.T) { + complete, next, err := mailPage(map[string]any{"nextCursor": "$"}, "mail/search", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("terminal dollar cursor mismatch: complete=%v next=%q err=%v", complete, next, err) + } + complete, next, err = mailPage(map[string]any{"hasMore": "false", "nextCursor": ""}, "mail/list", "", "") + if err != nil || !complete || next != "" { + t.Fatalf("string false pagination mismatch: complete=%v next=%q err=%v", complete, next, err) + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": ""}, "mail/list", "", ""); err == nil { + t.Fatal("hasMore without a progressing cursor must fail") + } + if _, _, err := mailPage(map[string]any{"hasMore": true, "nextCursor": "cursor-1"}, "mail/list", "", "cursor-1"); err == nil { + t.Fatal("repeated cursor must fail closed") + } + if err := mailRequireIdentity(map[string]any{"id": "actual"}, "mail/get", "expected", "id"); err == nil { + t.Fatal("identity mismatch must fail") + } +} + +func TestCrossPlatformCoverageMailUserProjectionOmitsEmptyOptionalIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "user id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "userId", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "userId"}, + {name: "numeric user id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "userId", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + rows, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{tc.user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ) + if err != nil || len(rows) != 1 { + t.Fatalf("rows=%v err=%v", rows, err) + } + if _, ok := rows[0][tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, rows[0]) + } + if _, ok := rows[0][tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, rows[0]) + } + }) + } + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{map[string]any{"id": "", "email": ""}}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("user without a stable identity unexpectedly projected") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := mailProjectCollection( + map[string]any{"success": true, "users": []any{user}}, + "mail/search_mail_users", "users", []string{"id", "email"}, + map[string][]string{"userId": {"id"}, "email": {"email"}}, + ); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} diff --git a/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go new file mode 100644 index 00000000..722ee756 --- /dev/null +++ b/internal/shortcut/mail/mail_write_contract_cross_platform_coverage_test.go @@ -0,0 +1,504 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "context" + "fmt" + "io" + "maps" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "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 mailWriteCall struct { + tool string + args map[string]any +} + +type mailWriteContractCaller struct { + responses map[string][]string + errors map[string]error + calls []mailWriteCall +} + +func (caller *mailWriteContractCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, mailWriteCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, fmt.Errorf("unexpected Mail tool call: %s", tool) + } + text := queue[0] + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*mailWriteContractCaller) Format() string { return "json" } +func (*mailWriteContractCaller) DryRun() bool { return false } +func (*mailWriteContractCaller) Fields() string { return "" } +func (*mailWriteContractCaller) JQ() string { return "" } + +func runConfirmedMailWriteContract(t *testing.T, caller *mailWriteContractCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + root.AddCommand(shortcut.Commands()...) + root.SetArgs(append(args, "--yes", "--format", "json")) + return root.Execute() +} + +func mailDraftReadback(id, subject, body, sender, to, cc string) string { + return fmt.Sprintf(`{"success":true,"message":{"id":%q,"subject":%q,"markdownBody":%q,"from":{"email":%q},"toRecipients":[{"email":%q}],"ccRecipients":[{"email":%q}]}}`, id, subject, body, sender, to, cc) +} + +func TestCrossPlatformCoverageMailWriteHappyPathsUseReceiptThenExactReadback(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantTools []string + }{ + { + name: "draft create", + args: []string{"mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"create_draft", "get_email_by_message_id"}, + }, + { + name: "draft edit", + args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_draft": {`{"success":true,"result":{"message":{"id":"draft-1"}}}`}, + "get_email_by_message_id": {mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid")}, + }, + wantTools: []string{"update_draft", "get_email_by_message_id"}, + }, + { + name: "template create core fields", + args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "create_user_message_template": {`{"success":true,"id":"template-1"}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"create_user_message_template", "get_user_message_template"}, + }, + { + name: "template update success plus requested id readback", + args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body"}, + responses: map[string][]string{ + "update_user_message_template": {`{"success":true}`}, + "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}, + }, + wantTools: []string{"update_user_message_template", "get_user_message_template"}, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("write execution failed: %v", err) + } + if len(caller.calls) != len(tc.wantTools) { + t.Fatalf("calls=%d want=%d", len(caller.calls), len(tc.wantTools)) + } + for index, tool := range tc.wantTools { + if caller.calls[index].tool != tool { + t.Fatalf("call[%d]=%s want=%s", index, caller.calls[index].tool, tool) + } + } + }) + } +} + +func TestCrossPlatformCoverageMailDraftWritesFailClosedOnReceiptAndReadback(t *testing.T) { + tests := []struct { + name string + writeBody string + readBody string + writeErr error + wantCalls int + }{ + {name: "empty write body", writeBody: "", wantCalls: 1}, + {name: "missing receipt", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "malformed receipt", writeBody: `{"success":true,"result":{"message":"bad"}}`, wantCalls: 1}, + {name: "missing receipt id", writeBody: `{"success":true,"result":{"message":{"subject":"x"}}}`, wantCalls: 1}, + {name: "missing readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "malformed readback object", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: `{"success":true,"message":"bad"}`, wantCalls: 2}, + {name: "wrong readback id", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "request field mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "recipient mismatch", writeBody: `{"success":true,"result":{"message":{"id":"draft-1"}}}`, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "write failure", writeErr: fmt.Errorf("write failed"), wantCalls: 1}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"create_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: map[string]error{"create_draft": tc.writeErr}, + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-create", "--from", "sender@example.invalid", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if err == nil { + t.Fatal("invalid receipt/readback unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailEditAndTemplateNegativeCallHistory(t *testing.T) { + tests := []struct { + name string + args []string + responses map[string][]string + wantCalls int + }{ + {name: "draft edit no change", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "draft edit wrong receipt id", args: []string{"mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--subject", "subject"}, responses: map[string][]string{"update_draft": {`{"success":true,"result":{"message":{"id":"other"}}}`}}, wantCalls: 1}, + {name: "template update no change", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template update missing success", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{}`}}, wantCalls: 1}, + {name: "template update wrong readback id", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template update field mismatch", args: []string{"mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--subject", "subject"}, responses: map[string][]string{"update_user_message_template": {`{"success":true}`}, "get_user_message_template": {`{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create missing id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true}`}}, wantCalls: 1}, + {name: "template create wrong readback id", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{"create_user_message_template": {`{"success":true,"id":"template-1"}`}, "get_user_message_template": {`{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`}}, wantCalls: 2}, + {name: "template create unverifiable from", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--from", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body"}, responses: map[string][]string{}, wantCalls: 0}, + {name: "template create unverifiable draft", args: []string{"mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body", "--draft"}, responses: map[string][]string{}, wantCalls: 0}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: tc.responses, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid write unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + args []string + tool string + response string + }{ + {name: "thread list", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1"}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + {name: "user search", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "template list", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[{"id":"template-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "contact list", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[{"id":"contact-1"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor was not passed through: %#v", caller.calls) + } + }) + } + caller := &mailWriteContractCaller{ + responses: map[string][]string{"search_mail_users": {`{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}}, + errors: map[string]error{}, + } + if err := runConfirmedMailWriteContract(t, caller, "mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageMailValidationRejectsBeforeRemoteCall(t *testing.T) { + tooManyIDs := make([]string, 101) + for index := range tooManyIDs { + tooManyIDs[index] = fmt.Sprintf("message-%d", index) + } + for _, tc := range []struct { + name string + args []string + }{ + {name: "thread zero limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "0"}}, + {name: "thread high limit", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "101"}}, + {name: "thread bad start", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "bad"}}, + {name: "thread non utc", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00+08:00"}}, + {name: "thread reversed", args: []string{"mail", "+thread-list", "--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--start", "2026-08-18T10:00:00Z", "--end", "2026-08-18T09:00:00Z"}}, + {name: "user zero limit", args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "0"}}, + {name: "template nonnumeric limit", args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "bad"}}, + {name: "contact high limit", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "101"}}, + {name: "messages empty item", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", "message-1", "--ids", " "}}, + {name: "messages too many", args: []string{"mail", "+messages", "--email", "mail@example.invalid", "--ids", strings.Join(tooManyIDs, ",")}}, + {name: "user empty keyword", args: []string{"mail", "+user-search", "--keyword", " "}}, + {name: "user empty employee number", args: []string{"mail", "+user-search", "--keyword", "fixture", "--employee-no", " "}}, + {name: "message empty id", args: []string{"mail", "+message", "--id", " "}}, + {name: "thread empty id", args: []string{"mail", "+thread", "--id", " "}}, + {name: "folder empty email", args: []string{"mail", "+folder-list", "--email", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err == nil { + t.Fatal("invalid input unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid input reached remote calls: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsStayCompatibleAndStrict(t *testing.T) { + if err := mailValidatePageSize(mailRuntimeForCoverage(t, ThreadList, nil), "limit", false); err != nil { + t.Fatalf("omitted optional integer limit should remain valid: %v", err) + } + + tests := []struct { + name string + declaration *shortcut.Shortcut + args []string + tool string + response string + wantSize string + wantPresent bool + }{ + {name: "user limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture", "--limit", "1"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`, wantSize: "1", wantPresent: true}, + {name: "user omitted limit", declaration: &UserSearch, args: []string{"mail", "+user-search", "--keyword", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[],"hasMore":false,"nextCursor":""}`}, + {name: "template limit", declaration: &TemplateList, args: []string{"mail", "+template-list", "--email", "mail@example.invalid", "--limit", "100"}, tool: "list_user_message_templates", response: `{"success":true,"templates":[],"hasMore":false,"nextCursor":""}`, wantSize: "100", wantPresent: true}, + {name: "contact limit", declaration: &ContactList, args: []string{"mail", "+contact-list", "--email", "mail@example.invalid", "--limit", "20"}, tool: "list_user_mail_contacts", response: `{"success":true,"contacts":[],"hasMore":false,"nextCursor":""}`, wantSize: "20", wantPresent: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + found := false + for _, flag := range tc.declaration.Flags { + if flag.Name != "limit" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("declaration --limit type/default = %q/%q, want string/empty", flag.Type, flag.Default) + } + } + if !found { + t.Fatal("declaration missing --limit") + } + cmd := corecmd.New(shortcut.FromShortcut(*tc.declaration)) + if flag := cmd.Flags().Lookup("limit"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("Cobra --limit = %#v, want historical string with empty default", flag) + } + + caller := &mailWriteContractCaller{responses: map[string][]string{tc.tool: {tc.response}}, errors: map[string]error{}} + if err := runConfirmedMailWriteContract(t, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want 1", len(caller.calls)) + } + gotSize, present := caller.calls[0].args["size"] + if present != tc.wantPresent || (present && gotSize != tc.wantSize) { + t.Fatalf("size=%#v present=%v, want %q present=%v", gotSize, present, tc.wantSize, tc.wantPresent) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + values map[string]string + }{ + {name: "user execute guard", declaration: UserSearch, values: map[string]string{"keyword": "fixture", "limit": "bad"}}, + {name: "template execute guard", declaration: TemplateList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + {name: "contact execute guard", declaration: ContactList, values: map[string]string{"email": "mail@example.invalid", "limit": "bad"}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + helpers.InitDepsForTest(t, caller) + rt := mailRuntimeForCoverage(t, tc.declaration, tc.values) + if err := tc.declaration.Execute(rt); err == nil { + t.Fatal("direct Execute accepted malformed string limit") + } + if len(caller.calls) != 0 { + t.Fatalf("direct Execute reached %d remote calls", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageMailLegacyStringLimitsRejectEveryInvalidClassWithZeroCalls(t *testing.T) { + for _, leaf := range []struct { + name string + args []string + }{ + {name: "user", args: []string{"mail", "+user-search", "--keyword", "fixture"}}, + {name: "template", args: []string{"mail", "+template-list", "--email", "mail@example.invalid"}}, + {name: "contact", args: []string{"mail", "+contact-list", "--email", "mail@example.invalid"}}, + } { + for _, invalid := range []struct { + name string + value string + }{ + {name: "blank", value: " "}, + {name: "nonnumeric", value: "bad"}, + {name: "zero", value: "0"}, + {name: "negative", value: "-1"}, + {name: "too large", value: "101"}, + } { + t.Run(leaf.name+"/"+invalid.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{}, errors: map[string]error{}} + args := append(append([]string{}, leaf.args...), "--limit", invalid.value) + if err := runConfirmedMailWriteContract(t, caller, args...); err == nil { + t.Fatal("invalid legacy string limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid legacy string limit reached %d remote calls", len(caller.calls)) + } + }) + } + } +} + +func TestCrossPlatformCoverageMailDraftEditOwnNegativeBranches(t *testing.T) { + goodReceipt := `{"success":true,"result":{"message":{"id":"draft-1"}}}` + goodReadback := mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid") + for _, tc := range []struct { + name string + writeBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "receipt remote failure", writeBody: `{"success":false}`, wantCalls: 1}, + {name: "receipt missing", writeBody: `{"success":true}`, wantCalls: 1}, + {name: "receipt malformed", writeBody: `{"success":true,"result":{"message":[]}}`, wantCalls: 1}, + {name: "receipt wrong id", writeBody: `{"success":true,"result":{"message":{"id":"other"}}}`, wantCalls: 1}, + {name: "readback transport", writeBody: goodReceipt, errors: map[string]error{"get_email_by_message_id": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing", writeBody: goodReceipt, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback malformed", writeBody: goodReceipt, readBody: `{"success":true,"message":[]}`, wantCalls: 2}, + {name: "readback wrong id", writeBody: goodReceipt, readBody: mailDraftReadback("other", "subject", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "subject mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "other", "body", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "body mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "other", "sender@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "from mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "other@example.invalid", "to@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "to mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "other@example.invalid", "cc@example.invalid"), wantCalls: 2}, + {name: "cc mismatch", writeBody: goodReceipt, readBody: mailDraftReadback("draft-1", "subject", "body", "sender@example.invalid", "to@example.invalid", "other@example.invalid"), wantCalls: 2}, + {name: "happy control", writeBody: goodReceipt, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{ + responses: map[string][]string{"update_draft": {tc.writeBody}, "get_email_by_message_id": {tc.readBody}}, + errors: tc.errors, + } + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+draft-edit", "--from", "sender@example.invalid", "--id", "draft-1", "--to", "to@example.invalid", "--cc", "cc@example.invalid", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatalf("happy control: %v", err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateCreateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + createBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", createBody: `{"id":"template-1"}`, wantCalls: 1}, + {name: "remote failure", createBody: `{"success":false,"id":"template-1"}`, wantCalls: 1}, + {name: "missing id", createBody: `{"success":true}`, wantCalls: 1}, + {name: "readback transport", createBody: `{"success":true,"id":"template-1"}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", createBody: `{"success":true,"id":"template-1"}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", createBody: `{"success":true,"id":"template-1"}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"create_user_message_template": {tc.createBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-create", "--email", "sender@example.invalid", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageMailTemplateUpdateOwnNegativeBranches(t *testing.T) { + goodReadback := `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"body"}}` + for _, tc := range []struct { + name string + updateBody string + readBody string + errors map[string]error + wantCalls int + }{ + {name: "missing success", updateBody: `{}`, wantCalls: 1}, + {name: "remote failure", updateBody: `{"success":false}`, wantCalls: 1}, + {name: "readback transport", updateBody: `{"success":true}`, errors: map[string]error{"get_user_message_template": fmt.Errorf("read failed")}, wantCalls: 2}, + {name: "readback missing shape", updateBody: `{"success":true}`, readBody: `{"success":true}`, wantCalls: 2}, + {name: "readback wrong id", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"other","name":"name","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "name mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"other","message":{"subject":"subject","markdownBody":"body"}}`, wantCalls: 2}, + {name: "subject mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"other","markdownBody":"body"}}`, wantCalls: 2}, + {name: "body mismatch", updateBody: `{"success":true}`, readBody: `{"success":true,"id":"template-1","name":"name","message":{"subject":"subject","markdownBody":"other"}}`, wantCalls: 2}, + {name: "happy control", updateBody: `{"success":true}`, readBody: goodReadback, wantCalls: 2}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &mailWriteContractCaller{responses: map[string][]string{"update_user_message_template": {tc.updateBody}, "get_user_message_template": {tc.readBody}}, errors: tc.errors} + if caller.errors == nil { + caller.errors = map[string]error{} + } + err := runConfirmedMailWriteContract(t, caller, "mail", "+template-update", "--email", "sender@example.invalid", "--id", "template-1", "--name", "name", "--subject", "subject", "--body", "body") + if tc.name == "happy control" { + if err != nil { + t.Fatal(err) + } + } else if err == nil { + t.Fatal("negative branch unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} diff --git a/internal/shortcut/mail/reads.go b/internal/shortcut/mail/reads.go new file mode 100644 index 00000000..21feb47c --- /dev/null +++ b/internal/shortcut/mail/reads.go @@ -0,0 +1,222 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "strings" + + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailResolveMailbox(rt *shortcut.RuntimeContext) (string, error) { + if email := strings.TrimSpace(rt.Str("email")); email != "" { + return email, nil + } + data, err := rt.CallMCPData("mail", "list_user_mailboxes", nil) + if err != nil { + return "", err + } + mailboxes, err := mailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) == 0 { + return "", apperrors.NewValidation("当前身份没有可用邮箱,请用 --email 指定") + } + return mailboxes[0], nil +} + +// mailMailboxAddresses accepts the reviewed mailbox collection paths and item +// shapes used by the Mail gateway. The complete selected array is validated +// before the first address is returned so a malformed later row cannot be +// hidden behind an otherwise usable first mailbox. +func mailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := mailLookup(data, path) + if !present { + continue + } + if selectedPath != "" { + return nil, mailResponseError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) + } + value = candidate + selectedPath = path + } + if selectedPath == "" { + return nil, mailResponseError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") + } + + raw, ok := value.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) + } + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + rawEmail, present := typed["email"] + if present && rawEmail != nil { + var valid bool + address, valid = rawEmail.(string) + if !valid { + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d].email 必须是字符串", selectedPath, index)) + } + address = strings.TrimSpace(address) + } + default: + return nil, mailResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, mailResponseError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil +} + +func mailReadMessage(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_email_by_message_id", map[string]any{"email": email, "messageId": id}) + if err != nil { + return nil, err + } + message, err := mailRequireObject(data, "mail/get_email_by_message_id", "message") + if err != nil { + return nil, err + } + if err := mailRequireIdentity(message, "mail/get_email_by_message_id", id, "id"); err != nil { + return nil, err + } + return message, nil +} + +var Message = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+message", Product: "mail", + Description: "读取一封邮件的完整正文与附件元数据", Intent: "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+message", "读取一封邮件的完整正文与附件元数据", "已知单个 messageId,需要读取完整正文和附件元数据时使用;自动解析当前邮箱,并要求返回邮件 ID 与请求精确一致。", mailObjectResult("身份匹配的单封邮件详情"), []contract.ParamDecl{{Name: "id", Property: "messageId"}}, `dws mail +message --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件 messageId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + message, err := mailReadMessage(rt, email, rt.Str("id")) + if err != nil { + return err + } + return rt.Output(map[string]any{"value": message}) + }, +} + +var Messages = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+messages", Product: "mail", + Description: "按请求顺序读取多封邮件并逐封验证身份", Intent: "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+messages", "按请求顺序读取多封邮件并逐封验证身份", "需要一次读取多个 messageId 时使用;按输入顺序逐封读取,任何缺失、错型或身份不匹配都会使整次任务失败。", mailCollectionResult("messages", "身份匹配且保持请求顺序的邮件详情"), []contract.ParamDecl{{Name: "ids", Property: "messageIds"}}, `dws mail +messages --ids , --format json`), + Flags: []shortcut.Flag{ + {Name: "ids", Type: shortcut.FlagStringSlice, Required: true, Desc: "邮件 messageId 列表,1-100 个且每项不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{ + Kind: shortcut.ConstraintCustom, Flags: []string{"ids"}, + Description: "1-100 个且不能为空", + }}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateMessageIDs(rt.StrSlice("ids")) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + ids := rt.StrSlice("ids") + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + messages := make([]map[string]any, 0, len(ids)) + for index, id := range ids { + id = strings.TrimSpace(id) + if id == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + message, err := mailReadMessage(rt, email, id) + if err != nil { + return err + } + messages = append(messages, message) + } + return rt.Output(mailBusinessCollectionPayload("messages", messages)) + }, +} + +func mailValidateMessageIDs(ids []string) error { + if len(ids) == 0 || len(ids) > 100 { + return apperrors.NewValidation("--ids 需要 1 到 100 个邮件 ID") + } + for index, id := range ids { + if strings.TrimSpace(id) == "" { + return apperrors.NewValidation(fmt.Sprintf("--ids 第 %d 项为空", index)) + } + } + return nil +} + +var Thread = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+thread", Product: "mail", + Description: "读取完整邮件会话并精确验证 conversationId", Intent: "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", + Risk: shortcut.RiskRead, Safety: mailReadSafety(), + Contract: mailReadContract("+thread", "读取完整邮件会话并精确验证 conversationId", "已知一个 conversationId,需要查看同一主题的会话上下文时使用;自动解析邮箱并拒绝空对象或错会话。", mailObjectResult("身份匹配的邮件会话详情"), []contract.ParamDecl{{Name: "id", Property: "conversationId"}}, `dws mail +thread --id --format json`), + Flags: []shortcut.Flag{ + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "邮件会话 conversationId,不能为空"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"id"}, Description: "不能为空"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return mailValidateRequiredText(rt, "id") + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email, err := mailResolveMailbox(rt) + if err != nil { + return err + } + data, err := rt.CallMCPData("mail", "get_thread", map[string]any{"email": email, "conversationId": rt.Str("id")}) + if err != nil { + return err + } + conversation, err := mailRequireObject(data, "mail/get_thread", "conversation") + if err != nil { + return err + } + if err := mailRequireIdentity(conversation, "mail/get_thread", rt.Str("id"), "id"); err != nil { + return err + } + return rt.Output(map[string]any{"value": conversation}) + }, +} + +func init() { + shortcut.Register(Message, Messages, Thread) +} diff --git a/internal/shortcut/mail/thread_list_projection_test.go b/internal/shortcut/mail/thread_list_projection_test.go index c2f178d5..d34f639e 100644 --- a/internal/shortcut/mail/thread_list_projection_test.go +++ b/internal/shortcut/mail/thread_list_projection_test.go @@ -21,18 +21,23 @@ import ( // TestThreadListProjectConversationsShape guards the exact // list_mailbox_threads response contract, including lastModifiedDateTime. func TestThreadListProjectConversationsShape(t *testing.T) { - const raw = `{"result":{"conversations":[{ + const raw = `{"success":true,"result":{"conversations":[{ "id":"thread-1", "subject":"Projection contract", "lastModifiedDateTime":"2026-07-26T10:00:00Z", "isRead":false - }]}}` + }],"hasMore":false,"nextCursor":""}}` var data map[string]any if err := json.Unmarshal([]byte(raw), &data); err != nil { t.Fatalf("unmarshal fixture: %v", err) } - got := threadListProject(data) + got, err := mailProjectCollection(data, "mail/list_mailbox_threads", "result.conversations", []string{"id"}, map[string][]string{ + "conversationId": {"id"}, "subject": {"subject"}, "lastUpdated": {"lastModifiedDateTime"}, "isRead": {"isRead"}, + }) + if err != nil { + t.Fatalf("strict projection: %v", err) + } if len(got) != 1 { t.Fatalf("lower/upper mismatch: result.conversations has 1 entry, projection returned %d (%v)", len(got), got) } diff --git a/internal/shortcut/mail/writes.go b/internal/shortcut/mail/writes.go new file mode 100644 index 00000000..63989e89 --- /dev/null +++ b/internal/shortcut/mail/writes.go @@ -0,0 +1,397 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package mail + +import ( + "fmt" + "sort" + "strings" + + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func mailRecipients(values []string) []string { + out := make([]string, 0, len(values)) + for _, value := range values { + for _, part := range strings.Split(value, ",") { + if trimmed := strings.TrimSpace(part); trimmed != "" { + out = append(out, trimmed) + } + } + } + return out +} + +func mailNormalizedAddresses(values []string) []string { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + normalized := strings.ToLower(strings.TrimSpace(value)) + if normalized != "" { + seen[normalized] = struct{}{} + } + } + out := make([]string, 0, len(seen)) + for value := range seen { + out = append(out, value) + } + sort.Strings(out) + return out +} + +func mailReadAddresses(message map[string]any, operation, field string) ([]string, error) { + raw, present := message[field] + if !present { + return nil, mailResponseError(operation, "missing_verification_field", fmt.Sprintf("读回邮件缺少 %s", field)) + } + items, ok := raw.([]any) + if !ok { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s 应为数组", field)) + } + addresses := make([]string, 0, len(items)) + for index, item := range items { + var address string + switch typed := item.(type) { + case string: + address = typed + case map[string]any: + address = mailFirstString(typed, "email", "address") + default: + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 类型无效", field, index)) + } + if strings.TrimSpace(address) == "" { + return nil, mailResponseError(operation, "malformed_verification_field", fmt.Sprintf("读回邮件 %s[%d] 缺少邮箱地址", field, index)) + } + addresses = append(addresses, address) + } + return mailNormalizedAddresses(addresses), nil +} + +func mailVerifyAddresses(message map[string]any, operation, field string, requested []string) error { + actual, err := mailReadAddresses(message, operation, field) + if err != nil { + return err + } + expected := mailNormalizedAddresses(requested) + if len(actual) != len(expected) { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + for index := range expected { + if actual[index] != expected[index] { + return mailResponseError(operation, "verification_mismatch", fmt.Sprintf("读回邮件 %s 与请求不一致", field)) + } + } + return nil +} + +func mailVerifySender(message map[string]any, operation, requested string) error { + raw, present := message["from"] + if !present { + return mailResponseError(operation, "missing_verification_field", "读回邮件缺少 from") + } + var actual string + switch typed := raw.(type) { + case string: + actual = typed + case map[string]any: + actual = mailFirstString(typed, "email", "address") + default: + return mailResponseError(operation, "malformed_verification_field", "读回邮件 from 类型无效") + } + if !strings.EqualFold(strings.TrimSpace(actual), strings.TrimSpace(requested)) { + return mailResponseError(operation, "verification_mismatch", "读回邮件发件地址与请求不一致") + } + return nil +} + +func mailDraftMessage(data map[string]any, operation string) (map[string]any, error) { + if err := mailRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := mailLookup(data, "result.message") + if !present { + return nil, mailResponseError(operation, "missing_write_receipt", "写响应缺少 result.message") + } + message, ok := value.(map[string]any) + if !ok || len(message) == 0 { + return nil, mailResponseError(operation, "malformed_write_receipt", fmt.Sprintf("result.message 应为非空对象,实际为 %T", value)) + } + if err := mailRequireIdentity(message, operation, "", "id"); err != nil { + return nil, err + } + return message, nil +} + +func mailReadTemplate(rt *shortcut.RuntimeContext, email, id string) (map[string]any, error) { + data, err := rt.CallMCPData("mail", "get_user_message_template", map[string]any{"email": email, "id": id}) + if err != nil { + return nil, err + } + if err := mailRequireSuccess(data, "mail/get_user_message_template"); err != nil { + return nil, err + } + template := make(map[string]any, len(data)) + for key, value := range data { + switch key { + case "success", "errorCode", "errorMsg", "arguments": + continue + default: + template[key] = value + } + } + if len(template) == 0 { + return nil, mailResponseError("mail/get_user_message_template", "missing_result", "模板详情为空") + } + if err := mailRequireIdentity(template, "mail/get_user_message_template", id, "id"); err != nil { + return nil, err + } + return template, nil +} + +func mailTemplateSubject(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "subject") +} + +func mailTemplateBody(template map[string]any) string { + message, _ := template["message"].(map[string]any) + return mailFirstString(message, "markdownBody", "body") +} + +var DraftCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-create", Product: "mail", + Description: "创建邮件草稿并按 messageId 读回验证", Intent: "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+draft-create", "创建邮件草稿并按 messageId 读回验证", "撰写一封新邮件但暂不发送;取得稳定草稿 ID 后读取同一对象,并核对主题和正文。", []contract.ParamDecl{{Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-create --from user@company.com --subject "草稿" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "收件邮箱,可多次指定或逗号分隔"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "抄送邮箱,可多次指定或逗号分隔"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "草稿主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "草稿正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"from": rt.Str("from"), "subject": rt.Str("subject")} + if recipients := mailRecipients(rt.StrSlice("to")); len(recipients) > 0 { + params["toRecipients"] = recipients + } + if recipients := mailRecipients(rt.StrSlice("cc")); len(recipients) > 0 { + params["ccRecipients"] = recipients + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/create_draft") + if err != nil { + return err + } + id := mailFirstString(receipt, "id") + verified, err := mailReadMessage(rt, rt.Str("from"), id) + if err != nil { + return err + } + if mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/create_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/create_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/create_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var DraftEdit = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+draft-edit", Product: "mail", + Description: "更新已有草稿并精确读回验证", Intent: "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+draft-edit", "更新已有草稿并精确读回验证", "修改尚未发送的草稿;要求至少提供一个变化字段,并在写后按原 messageId 核对修改内容。", []contract.ParamDecl{{Name: "id", Property: "id"}, {Name: "from", Property: "from"}, {Name: "to", Property: "toRecipients"}, {Name: "cc", Property: "ccRecipients"}, {Name: "body", Property: "body"}}, `dws mail +draft-edit --from user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "from", Type: shortcut.FlagString, Required: true, Desc: "发件邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "草稿 messageId"}, + {Name: "to", Type: shortcut.FlagStringSlice, Desc: "新收件邮箱"}, + {Name: "cc", Type: shortcut.FlagStringSlice, Desc: "新抄送邮箱"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"to", "cc", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("to") && !rt.Changed("cc") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --to、--cc、--subject、--body 之一") + } + params := map[string]any{"from": rt.Str("from"), "id": rt.Str("id")} + if rt.Changed("to") { + params["toRecipients"] = mailRecipients(rt.StrSlice("to")) + } + if rt.Changed("cc") { + params["ccRecipients"] = mailRecipients(rt.StrSlice("cc")) + } + if rt.Changed("subject") { + params["subject"] = rt.Str("subject") + } + if rt.Changed("body") { + params["body"] = rt.Str("body") + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_draft"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_draft", params) + if err != nil { + return err + } + receipt, err := mailDraftMessage(written, "mail/update_draft") + if err != nil { + return err + } + if mailFirstString(receipt, "id") != rt.Str("id") { + return mailResponseError("mail/update_draft", "identity_mismatch", "更新回执的 messageId 与请求不一致") + } + verified, err := mailReadMessage(rt, rt.Str("from"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("subject") && mailFirstString(verified, "subject") != rt.Str("subject") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回主题与请求不一致") + } + if rt.Changed("body") && mailFirstString(verified, "markdownBody", "body") != rt.Str("body") { + return mailResponseError("mail/update_draft", "verification_mismatch", "草稿读回正文与请求不一致") + } + if err := mailVerifySender(verified, "mail/update_draft", rt.Str("from")); err != nil { + return err + } + if rt.Changed("to") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "toRecipients", mailRecipients(rt.StrSlice("to"))); err != nil { + return err + } + } + if rt.Changed("cc") { + if err := mailVerifyAddresses(verified, "mail/update_draft", "ccRecipients", mailRecipients(rt.StrSlice("cc"))); err != nil { + return err + } + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateCreate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-create", Product: "mail", + Description: "创建个人邮件模板并按模板 ID 读回", Intent: "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("non_idempotent"), + Contract: mailWriteContract("+template-create", "创建个人邮件模板并按模板 ID 读回", "保存可复用邮件主题和正文;创建后按稳定模板 ID 读取并核对名称、主题和正文。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "body", Property: "body"}}, `dws mail +template-create --email user@company.com --name "模板" --subject "主题" --body "正文" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "name", Type: shortcut.FlagString, Required: true, Desc: "模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Required: true, Desc: "模板主题"}, + {Name: "body", Type: shortcut.FlagString, Required: true, Desc: "模板正文"}, + }, + Execute: func(rt *shortcut.RuntimeContext) error { + params := map[string]any{"email": rt.Str("email"), "name": rt.Str("name"), "subject": rt.Str("subject"), "body": rt.Str("body")} + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/create_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "create_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/create_user_message_template"); err != nil { + return err + } + id := mailFirstString(written, "id") + if id == "" { + return mailResponseError("mail/create_user_message_template", "missing_stable_id", "创建模板响应缺少稳定 ID") + } + verified, err := mailReadTemplate(rt, rt.Str("email"), id) + if err != nil { + return err + } + if mailFirstString(verified, "name") != rt.Str("name") || mailTemplateSubject(verified) != rt.Str("subject") || mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/create_user_message_template", "verification_mismatch", "模板读回内容与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +var TemplateUpdate = shortcut.Shortcut{ + OutputRollout: output.RolloutUnifiedActive, + Service: "mail", Command: "+template-update", Product: "mail", + Description: "更新草稿模板并按原 ID 读回验证", Intent: "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", + Risk: shortcut.RiskWrite, Safety: mailWriteSafety("unknown"), + Contract: mailWriteContract("+template-update", "更新草稿模板并按原 ID 读回验证", "修改可编辑的草稿模板;写响应成功后按原模板 ID 读取并核对所有请求变更。", []contract.ParamDecl{{Name: "email", Property: "email"}, {Name: "id", Property: "id"}, {Name: "body", Property: "body"}}, `dws mail +template-update --email user@company.com --id --subject "新主题" --format json`), + Flags: []shortcut.Flag{ + {Name: "email", Type: shortcut.FlagString, Required: true, Desc: "模板所属邮箱"}, + {Name: "id", Type: shortcut.FlagString, Required: true, Desc: "模板 ID;仅草稿模板可更新"}, + {Name: "name", Type: shortcut.FlagString, Desc: "新模板名称"}, + {Name: "subject", Type: shortcut.FlagString, Desc: "新主题"}, + {Name: "body", Type: shortcut.FlagString, Desc: "新正文"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintAtLeastOne, Flags: []string{"name", "subject", "body"}}}, + Execute: func(rt *shortcut.RuntimeContext) error { + if !rt.Changed("name") && !rt.Changed("subject") && !rt.Changed("body") { + return apperrors.NewValidation("至少指定 --name、--subject、--body 之一") + } + params := map[string]any{"email": rt.Str("email"), "id": rt.Str("id")} + for _, flag := range []string{"name", "subject", "body"} { + if rt.Changed(flag) { + params[flag] = rt.Str(flag) + } + } + if rt.DryRun() { + return rt.Output(map[string]any{"value": map[string]any{"dryRun": true, "executed": false, "operation": "mail/update_user_message_template"}}) + } + written, err := rt.CallMCPWriteDataStrict("mail", "update_user_message_template", params) + if err != nil { + return err + } + if err := mailRequireSuccess(written, "mail/update_user_message_template"); err != nil { + return err + } + verified, err := mailReadTemplate(rt, rt.Str("email"), rt.Str("id")) + if err != nil { + return err + } + if rt.Changed("name") && mailFirstString(verified, "name") != rt.Str("name") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回名称与请求不一致") + } + if rt.Changed("subject") && mailTemplateSubject(verified) != rt.Str("subject") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回主题与请求不一致") + } + if rt.Changed("body") && mailTemplateBody(verified) != rt.Str("body") { + return mailResponseError("mail/update_user_message_template", "verification_mismatch", "模板读回正文与请求不一致") + } + return rt.Output(map[string]any{"value": verified}) + }, +} + +func init() { + markMailUnavailable(&DraftCreate, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&DraftEdit, "真实草稿连续两次 batch-delete 后仍能按同 messageId 读取,无法证明安全 fixture 的清理终态。") + markMailUnavailable(&TemplateCreate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + markMailUnavailable(&TemplateUpdate, "模板 delete 后 get 仅返回未分类失败,无 typed nonfound/tombstone 证据可证明零残留。") + shortcut.Register(DraftCreate, DraftEdit, TemplateCreate, TemplateUpdate) +} diff --git a/internal/shortcut/minutesdata/workflow.go b/internal/shortcut/minutesdata/workflow.go index 4ffc9a83..236f77b7 100644 --- a/internal/shortcut/minutesdata/workflow.go +++ b/internal/shortcut/minutesdata/workflow.go @@ -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 diff --git a/internal/shortcut/minutesdata/workflow_test.go b/internal/shortcut/minutesdata/workflow_test.go index b0bccb58..e566b645 100644 --- a/internal/shortcut/minutesdata/workflow_test.go +++ b/internal/shortcut/minutesdata/workflow_test.go @@ -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" { diff --git a/internal/shortcut/oa/common.go b/internal/shortcut/oa/common.go new file mode 100644 index 00000000..6e09854c --- /dev/null +++ b/internal/shortcut/oa/common.go @@ -0,0 +1,415 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const oaCompositeReason = "Reviewed OA Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable identity checks, truthful pagination, output projection, and confirmation; no single MCP interface represents the complete command contract." + +type oaPageEvidence struct { + Known bool + HasMore bool + Next string +} + +func oaCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前响应中的有效审批记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格验证后的 OA 审批业务记录","additionalProperties":true}},"complete":{"type":"boolean","description":"服务端分页证据是否证明结果完整"}},"required":["count",%q,"complete"],"additionalProperties":true}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".title", collection + ".originatorName", collection + ".formValueVOS", collection + ".userId"}, + } +} + +func oaObjectResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"value":{"type":"object","description":"严格验证且身份匹配的 OA 审批对象","additionalProperties":true}},"required":["value"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"value.title", "value.processInstanceTitle", "value.formValueVOS", "value.originatorUserid", "value.operationRecords", "value.userId"}, + } +} + +func oaWriteResult(description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"processInstanceId":{"type":"string","description":"被处理审批实例的稳定 ID"},"taskId":{"type":"string","description":"被处理审批任务的稳定 ID"},"verified":{"type":"boolean","description":"写后精确任务读回是否证明目标任务不再待处理"}},"required":["processInstanceId","taskId","verified"],"additionalProperties":false}`, + description, + )), + SensitivePaths: []string{"processInstanceId", "taskId"}, + } +} + +func oaReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func oaWriteSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "write", Risk: "high", Confirmation: "user_required", Idempotency: "unknown"} +} + +func oaContract(command, description, intent string, available bool, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "oa " + command + availability := contract.InterfaceUnavailable + interfaceReason := strings.TrimSpace(intent) + if available { + availability = contract.InterfaceAvailable + interfaceReason = oaCompositeReason + } + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "oa", Name: name, CanonicalPath: "oa." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: availability, Reason: interfaceReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"只需原始 OA MCP 响应时使用 oa approval 原子命令;缺少稳定实例、任务或表单身份时不要猜测"}, + Examples: examples, + }, + } +} + +func oaPagePagination(parameter string) *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: parameter, + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func oaResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func oaPostWriteError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("write_verification"), + apperrors.WithExecutionStarted(true), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// oaRequireSuccess accepts only the two success encodings observed from the +// OA backend. Missing, false, null, and all other encodings fail closed. +func oaRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return oaResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 OA 调用成功或结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return oaResponseError(operation, "missing_success", "OA 响应缺少 success 业务状态") + } + message := oaFirstString(data, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "OA 服务未明确返回成功状态" + } + return oaResponseError(operation, "remote_failure", message) +} + +func oaLookup(object map[string]any, path string) (any, bool) { + var current any = object + for _, segment := range strings.Split(path, ".") { + mapped, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = mapped[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func oaRequireObject(data map[string]any, operation, path string) (map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present || value == nil { + return nil, oaResponseError(operation, "missing_result", fmt.Sprintf("成功响应缺少非空 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_result", fmt.Sprintf("响应 %s 应为非空对象,实际为 %T", path, value)) + } + return object, nil +} + +func oaRequireCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := oaRequireSuccess(data, operation); err != nil { + return nil, err + } + value, present := oaLookup(data, path) + if !present { + return nil, oaResponseError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, oaResponseError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func oaFirstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func oaScalarString(value any) string { + switch typed := value.(type) { + case string: + return strings.TrimSpace(typed) + case json.Number: + return typed.String() + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case int: + return strconv.Itoa(typed) + case int64: + return strconv.FormatInt(typed, 10) + default: + return "" + } +} + +func oaIdentity(object map[string]any, keys ...string) string { + for _, key := range keys { + if value := oaScalarString(object[key]); value != "" { + return value + } + } + return "" +} + +func oaRequireIdentity(object map[string]any, operation, expected string, keys ...string) error { + actual := oaIdentity(object, keys...) + if actual == "" { + return oaResponseError(operation, "missing_stable_id", "OA 业务对象缺少稳定 ID") + } + if expected != "" && actual != expected { + return oaResponseError(operation, "identity_mismatch", "OA 业务对象 ID 与请求目标不一致") + } + return nil +} + +func oaProjectForms(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + forms := make([]map[string]any, 0, len(items)) + for index, item := range items { + code := oaIdentity(item, "processCode") + name := oaFirstString(item, "processName", "name") + if code == "" || name == "" { + return nil, oaResponseError(operation, "malformed_item", fmt.Sprintf("表单结果第 %d 项缺少 processCode 或名称", index)) + } + row := map[string]any{"processCode": code, "name": name} + if icon := oaFirstString(item, "processIconUrl", "iconUrl"); icon != "" { + row["iconUrl"] = icon + } + forms = append(forms, row) + } + return forms, nil +} + +func oaProjectInstances(data map[string]any, operation, path string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, path) + if err != nil { + return nil, err + } + instances := make([]map[string]any, 0, len(items)) + for index, item := range items { + id := oaIdentity(item, "processInstanceId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批结果第 %d 项缺少 processInstanceId", index)) + } + row := map[string]any{"processInstanceId": id} + if title := oaFirstString(item, "title", "processInstanceTitle"); title != "" { + row["title"] = title + } + if status := oaScalarString(item["status"]); status != "" { + row["status"] = status + } + if businessID := oaIdentity(item, "businessId"); businessID != "" { + row["businessId"] = businessID + } + if originator := oaFirstString(item, "originatorName", "originatorUserName"); originator != "" { + row["originatorName"] = originator + } + for _, key := range []string{"processCreateTime", "createTime"} { + if value, present := item[key]; present && value != nil { + row["createTime"] = value + break + } + } + instances = append(instances, row) + } + return instances, nil +} + +func oaProjectTasks(data map[string]any, operation string) ([]map[string]any, error) { + items, err := oaRequireCollection(data, operation, "result.taskIdList") + if err != nil { + return nil, err + } + tasks := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id := oaIdentity(item, "taskId") + if id == "" { + return nil, oaResponseError(operation, "missing_item_identity", fmt.Sprintf("审批任务第 %d 项缺少 taskId", index)) + } + if _, duplicate := seen[id]; duplicate { + return nil, oaResponseError(operation, "duplicate_item_identity", "审批任务响应包含重复 taskId") + } + seen[id] = struct{}{} + tasks = append(tasks, map[string]any{"taskId": id}) + } + return tasks, nil +} + +func oaCursorPage(result map[string]any, operation string, current int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "游标响应缺少 hasMore;不能把当前页当作完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + next := oaScalarString(result["nextCursor"]) + if !hasMore { + if next != "" { + return oaPageEvidence{}, oaResponseError(operation, "conflicting_pagination", "hasMore=false 但 nextCursor 非空") + } + return page, nil + } + if next == "" { + return oaPageEvidence{}, oaResponseError(operation, "missing_next_cursor", "hasMore=true 但缺少 nextCursor") + } + nextValue, err := strconv.Atoi(next) + if err != nil { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "nextCursor 应为整数") + } + if nextValue <= current { + return oaPageEvidence{}, oaResponseError(operation, "stalled_cursor", "nextCursor 没有严格前进") + } + page.Next = next + return page, nil +} + +func oaHasMorePage(result map[string]any, operation string, currentPage int) (oaPageEvidence, error) { + raw, present := result["hasMore"] + if !present { + return oaPageEvidence{}, oaResponseError(operation, "missing_pagination", "审批列表缺少 hasMore,无法证明结果完整或提供续页凭据") + } + hasMore, ok := raw.(bool) + if !ok { + return oaPageEvidence{}, oaResponseError(operation, "malformed_pagination", "OA 响应 hasMore 应为布尔值") + } + page := oaPageEvidence{Known: true, HasMore: hasMore} + if hasMore { + if currentPage <= 0 { + return oaPageEvidence{}, oaResponseError(operation, "invalid_page", "当前页码无效,无法生成下一页凭据") + } + page.Next = strconv.Itoa(currentPage + 1) + } + return page, nil +} + +func outputOAPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, page oaPageEvidence) error { + if !page.Known { + return oaResponseError("oa/pagination", "missing_pagination", "响应缺少分页终态证据") + } + payload := map[string]any{"count": len(items), collection: items, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextPage"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return oaResponseError("oa/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(items)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func outputOACompleteCollection(rt *shortcut.RuntimeContext, collection string, items []map[string]any) error { + payload := map[string]any{"count": len(items), collection: items, "complete": true} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} + +func validateOAPage(page, limit int) error { + if page <= 0 { + return apperrors.NewValidation("--page 必须大于 0") + } + if limit <= 0 || limit > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil +} diff --git a/internal/shortcut/oa/list_forms_projection_test.go b/internal/shortcut/oa/list_forms_projection_test.go deleted file mode 100644 index 21ce7df8..00000000 --- a/internal/shortcut/oa/list_forms_projection_test.go +++ /dev/null @@ -1,82 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package oa - -import ( - "encoding/json" - "testing" -) - -// TestListFormsProjectProcessCodeListShape guards against the projection-data-loss -// class: list_user_visible_process nests the forms under result.processCodeList. -// The resolver MUST probe that exact key — otherwise the whole list silently -// projects to empty (exit 0, no error envelope) while the backend has data. -func TestListFormsProjectProcessCodeListShape(t *testing.T) { - // Faithful list_user_visible_process shape (as returned by the backend). - const raw = `{"result":{"processCodeList":[ - {"processCode":"PROC-1","processName":"leave","dirName":"attendance"}, - {"processCode":"PROC-2","processName":"check-in fix","dirName":"attendance"}, - {"processCode":"PROC-3","processName":"overtime","dirName":"attendance"} - ],"totalCount":-1},"success":true}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - - forms := listFormsProject(data) - if len(forms) != 3 { - t.Fatalf("lower/upper mismatch: 3 forms in backend, projection returned %d (forms=%v)", len(forms), forms) - } - for _, f := range forms { - if f["processCode"] == nil || f["name"] == nil { - t.Fatalf("projected form missing processCode/name: %v", f) - } - } -} - -// TestListFormsProjectBareArrayShape ensures a bare top-level array still works. -func TestListFormsProjectBareArrayShape(t *testing.T) { - const raw = `{"result":[ - {"processCode":"PROC-9","processName":"generic approval"} - ]}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if forms := listFormsProject(data); len(forms) != 1 { - t.Fatalf("bare array shape: want 1 form, got %d (%v)", len(forms), forms) - } -} - -// TestOAInstanceResolveListValuesShape guards the shared resolver behind -// +list-pending / +list-executed / +list-submitted / +list-cc. Those approval -// instance tools nest the list under result.values; the resolver must probe -// "values" or all four commands silently return empty despite backend records. -func TestOAInstanceResolveListValuesShape(t *testing.T) { - const raw = `{"result":{"hasMore":false,"values":[ - {"processInstanceId":"i-1","title":"user leave"}, - {"processInstanceId":"i-2","title":"user reimbursement"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := oaInstanceResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.values has 2 entries, resolver returned %d", len(got)) - } - // End-to-end through a real projection that uses the shared resolver. - if instances := listSubmittedProject(data); len(instances) != 2 { - t.Fatalf("listSubmittedProject: want 2, got %d (%v)", len(instances), instances) - } -} diff --git a/internal/shortcut/oa/oa.go b/internal/shortcut/oa/oa.go index 80d1590d..b5ab751c 100644 --- a/internal/shortcut/oa/oa.go +++ b/internal/shortcut/oa/oa.go @@ -1,59 +1,73 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package oa registers declarative shortcuts for the DingTalk OA approval -// service, wrapping the raw MCP tools exposed by the dws oa helper. +// Package oa registers strict declarative shortcuts for DingTalk OA approval. package oa import ( - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "fmt" + "strconv" + "strings" + "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// ListPending — 查询待我处理的审批 (list_pending_approvals) +func parseOAStringPage(rt *shortcut.RuntimeContext, name string, fallback int) (int, error) { + raw := rt.Str(name) + if raw == "" { + return fallback, nil + } + value, err := strconv.Atoi(raw) + if err != nil { + return 0, apperrors.NewValidation(fmt.Sprintf("--%s 必须是整数", name)) + } + return value, nil +} + +func oaInstancePage(rt *shortcut.RuntimeContext, tool string, params map[string]any, page int) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + evidence, err := oaHasMorePage(result, operation, page) + if err != nil { + return err + } + return outputOAPage(rt, "instances", instances, evidence) +} + var ListPending = shortcut.Shortcut{ - Service: "oa", - Command: "+list-pending", - Product: "oa", - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Intent: "当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_pending", - CanonicalPath: "oa.shortcut_list_pending", - CLIPath: "oa +list-pending", - PrimaryCLIPath: "oa +list-pending", + Service: "oa", Command: "+list-pending", Product: "oa", + Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", + Intent: "按 epoch 毫秒时间范围查询当前用户待处理审批;只有显式成功、严格实例数组与可续页证据齐全时才返回结果。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-pending", + "查询待我处理的审批(时间范围为 epoch 毫秒)", + "需要按时间范围读取待我审批的实例,并取得稳定 processInstanceId 时使用;没有安全非空待办 fixture 前不会进入公开发现。", + true, + oaCollectionResult("instances", "严格验证的待处理审批实例页"), + oaPagePagination("page"), + []contract.ParamDecl{ + {Name: "start", Property: "start"}, + {Name: "end", Property: "end"}, + {Name: "page", Property: "page"}, + {Name: "limit", Property: "limit"}, + {Name: "query", Property: "query"}, }, - Description: "查询待我处理的审批(时间范围为 epoch 毫秒)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "查询待我处理的审批(时间范围为 epoch 毫秒)", - UseWhen: []string{"当你想知道自己当前有哪些审批还没处理、需要清理审批待办或按时间段/关键字盘点待审批单时使用;传入起止时间(epoch 毫秒,可选关键字与分页),返回待我审批的实例列表,是后续 +get 查详情、+approve/+reject 处理的入口。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销"}, - }, - }, + "dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20", + ), Flags: []shortcut.Flag{ {Name: "start", Type: shortcut.FlagInt, Desc: "开始时间(epoch 毫秒)", Required: true}, {Name: "end", Type: shortcut.FlagInt, Desc: "结束时间(epoch 毫秒)", Required: true}, @@ -61,12 +75,25 @@ var ListPending = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagString, Desc: "每页大小"}, {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "starTime": rt.Int("start"), - "endTime": rt.Int("end"), + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end", "page", "limit"}, Description: "--start/--end 必须是递增的正整数 epoch 毫秒;--page 必须大于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-pending --start 1741536000000 --end 1741622399000 --page 1 --limit 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("start") <= 0 || rt.Int("end") <= rt.Int("start") { + return apperrors.NewValidation("--start/--end 必须是递增的正整数 epoch 毫秒范围") } + page, err := parseOAStringPage(rt, "page", 1) + if err != nil { + return err + } + limit, err := parseOAStringPage(rt, "limit", 20) + if err != nil { + return err + } + return validateOAPage(page, limit) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"starTime": rt.Int("start"), "endTime": rt.Int("end")} if rt.Changed("page") { params["pageNum"] = rt.Str("page") } @@ -76,530 +103,154 @@ var ListPending = shortcut.Shortcut{ if rt.Changed("query") { params["query"] = rt.Str("query") } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - instances := listPendingProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return oaInstancePage(rt, "list_pending_approvals", params, page) }, } -// listPendingProject reshapes the raw list_pending_approvals response into the -// same clean {processInstanceId, title, status, createTime} approval list as -// +list-executed/+list-submitted, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listPendingProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// Detail — 获取审批实例详情 (get_processInstance_detail) -// Approve — 同意审批 (approve_processInstance) -// Reject — 拒绝审批 (reject_processInstance) -// Revoke — 撤销已发起的审批 (revoke_processInstance) -// Records — 获取审批操作记录 (get_processInstance_records) -// ListInitiated — 查询审批模板下已发起的审批记录 (list_initiated_instances) -// ListTasks — 查询待我审批的任务 ID (list_pending_tasks) -// ListForms — 获取当前用户可见的审批表单列表 (list_user_visible_process) var ListForms = shortcut.Shortcut{ - Service: "oa", - Command: "+list-forms", - Product: "oa", - Description: "获取当前用户可见的审批表单列表", - Intent: "当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_forms", - CanonicalPath: "oa.shortcut_list_forms", - CLIPath: "oa +list-forms", - PrimaryCLIPath: "oa +list-forms", - }, - Description: "获取当前用户可见的审批表单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户可见的审批表单列表", - UseWhen: []string{"当你想浏览自己有权限发起哪些审批模板、或需要为 +list-initiated 等操作枚举 processCode 时使用;无需关键字,按游标分页返回当前用户可见的全部审批表单,适合不确定表单名称时先整体看一遍。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-forms --cursor 0 --limit 100"}, - }, - }, + Service: "oa", Command: "+list-forms", Product: "oa", + Description: "获取当前用户可见的审批表单列表", + Intent: "按服务端游标读取当前用户可发起的审批表单;缺少 hasMore/nextCursor 或游标不前进时失败,不把重复首页宣称为完整列表。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+list-forms", "获取当前用户可见的审批表单列表", + "需要枚举可发起审批定义并取得稳定 processCode 时使用;当前下游不返回可验证 continuation,故不进入 Agent 公开发现。", + true, + oaCollectionResult("forms", "严格验证的可见审批表单页"), oaPagePagination("cursor"), + []contract.ParamDecl{{Name: "cursor", Property: "cursor"}, {Name: "limit", Property: "limit"}}, + "dws oa +list-forms --cursor 0 --limit 100", + ), Flags: []shortcut.Flag{ {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, {Name: "limit", Type: shortcut.FlagInt, Default: "100", Desc: "每页大小,最大 100"}, }, - Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"cursor", "limit"}, Description: "--cursor 不能小于 0;--limit 必须在 1-100"}}, + Tips: []string{`dws oa +list-forms --cursor 0 --limit 100`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Int("cursor") < 0 { + return apperrors.NewValidation("--cursor 不能小于 0") + } + if rt.Int("limit") <= 0 || rt.Int("limit") > 100 { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{ - "cursor": rt.Int("cursor"), - "pageSize": rt.Int("limit"), - }) + const operation = "oa/list_user_visible_process" + data, err := rt.CallMCPData("oa", "list_user_visible_process", map[string]any{"cursor": rt.Int("cursor"), "pageSize": rt.Int("limit")}) if err != nil { return err } - forms := listFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) + forms, err := oaProjectForms(data, operation, "result.processCodeList") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaCursorPage(result, operation, rt.Int("cursor")) + if err != nil { + return err + } + return outputOAPage(rt, "forms", forms, page) }, } -// listFormsProject reshapes the raw list_user_visible_process response into a -// clean approval-form list ({processCode, name, iconUrl}) — the output-projection -// fidelity the framework applies to every list command. The list container and -// per-item field names are probed defensively across candidate keys so the -// projection tolerates response-shape drift rather than crashing or fabricating. -func listFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaFormResolveList locates the form list inside a response, tolerating a bare -// top-level array or nesting one level under a common envelope key. -func oaFormResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // list_user_visible_process nests the forms under result.processCodeList; - // probing must include that exact key (both at the top level in case the - // envelope is already unwrapped, and one level deeper under result/data) or - // the whole list silently projects to empty. - for _, key := range []string{"result", "data", "list", "items", "processList", "processCodeList", "forms"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "processList", "processCodeList", "forms", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaFormProjectItem picks the stable identity/label fields of a single approval -// form, probing candidate key spellings so it survives snake/camel drift. -func oaFormProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processCode", "process_code", "code"); ok { - row["processCode"] = v - } - if v, ok := oaFormFirst(m, "name", "processName", "process_name", "flowTitle"); ok { - row["name"] = v - } - if v, ok := oaFormFirst(m, "iconUrl", "icon_url", "iconName"); ok { - row["iconUrl"] = v - } - return row -} - -// oaFormFirst returns the first present candidate key's value. -func oaFormFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// SearchForms — 按关键字模糊搜索可见审批表单 (search_form) var SearchForms = shortcut.Shortcut{ - Service: "oa", - Command: "+search-forms", - Product: "oa", - Description: "按关键字模糊搜索当前用户可见的审批表单", - Intent: "当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", + Service: "oa", Command: "+search-forms", Product: "oa", + Description: "按关键字模糊搜索当前用户可见的审批表单", + Intent: "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+search-forms", "按关键字模糊搜索当前用户可见的审批表单", + "已知审批定义关键字,需要取得一个或多个稳定 processCode 时使用;要无条件遍历全部定义不要使用本命令。", + true, + oaCollectionResult("forms", "严格验证的审批表单搜索结果"), nil, + []contract.ParamDecl{{Name: "query", Property: "query"}}, + "dws oa +search-forms --query 报销", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称);去除空白后不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 去除空白后不能为空"}}, + Tips: []string{`dws oa +search-forms --query 报销`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空") + } + return nil }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_search_forms", - CanonicalPath: "oa.shortcut_search_forms", - CLIPath: "oa +search-forms", - PrimaryCLIPath: "oa +search-forms", - }, - Description: "按关键字模糊搜索当前用户可见的审批表单", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "按关键字模糊搜索当前用户可见的审批表单", - UseWhen: []string{"当你已知想找的审批大致名称(如「报销」「请假」)、想快速定位对应表单及其 processCode 时使用,比 +list-forms 全量列举更高效;传入关键字,返回名称或 processCode 匹配的表单,供后续 +list-initiated 按模板查询。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +search-forms --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字(匹配 processCode 或表单名称)", Required: true}, - }, - Tips: []string{`dws oa +search-forms --query 报销`}, Execute: func(rt *shortcut.RuntimeContext) error { - data, err := rt.CallMCPData("oa", "search_form", map[string]any{ - "query": rt.Str("query"), - }) + const operation = "oa/search_form" + data, err := rt.CallMCPData("oa", "search_form", map[string]any{"query": strings.TrimSpace(rt.Str("query"))}) if err != nil { return err } - forms := searchFormsProject(data) - return rt.Output(map[string]any{"count": len(forms), "forms": forms}) - }, -} - -// searchFormsProject reshapes the raw search_form response into the same clean -// {processCode, name, iconUrl} list as +list-forms, so both approval-form -// listings project identically. It reuses the shared oaForm* defensive probes, -// tolerating response-shape and key-spelling drift. -func searchFormsProject(data map[string]any) []map[string]any { - raw := oaFormResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaFormProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// DingInfo — 获取审批任务的被催办人 userId (oa_ding_user) -// ListExecuted — 获取当前用户已处理过的审批单列表 (get_done_tasks) -var ListExecuted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-executed", - Product: "oa", - Description: "获取当前用户已经处理过的审批单列表", - Intent: "当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_executed", - CanonicalPath: "oa.shortcut_list_executed", - CLIPath: "oa +list-executed", - PrimaryCLIPath: "oa +list-executed", - }, - Description: "获取当前用户已经处理过的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已经处理过的审批单列表", - UseWhen: []string{"当你想回顾自己历史上审批过(已同意/拒绝等)的单子、做复盘或查找某条已办审批时使用,区别于 +list-pending 的待办;按页码/关键字分页返回我已处理的审批单。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-executed --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-executed --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) + forms, err := oaProjectForms(data, operation, "result") if err != nil { return err } - instances := listExecutedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) + return outputOACompleteCollection(rt, "forms", forms) }, } -// listExecutedProject reshapes the raw get_done_tasks response into a clean -// approval-instance list ({processInstanceId, title, status, createTime}) — the -// the clean output projection applied to every list command. The -// list container and per-item field names are probed defensively across -// candidate keys so the projection tolerates response-shape drift rather than -// crashing or fabricating data. -func listExecutedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// oaInstanceResolveList locates the approval-instance list inside a response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func oaInstanceResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // The approval instance tools (list_pending_approvals, get_done_tasks, - // get_submitted_instances, get_noticed_instances) all nest the instance list - // under result.values; "values" MUST be in the probe set or every one of - // +list-pending / +list-executed / +list-submitted / +list-cc silently - // projects to empty despite the backend returning records. - for _, key := range []string{"result", "data", "list", "items", "values", "instances", "tasks"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "values", "instances", "tasks", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// oaInstanceProjectItem picks the stable identity/label fields of a single -// approval instance, probing candidate key spellings so it survives -// snake/camel drift. -func oaInstanceProjectItem(m map[string]any) map[string]any { - row := map[string]any{} - if v, ok := oaFormFirst(m, "processInstanceId", "process_instance_id", "instanceId", "id"); ok { - row["processInstanceId"] = v - } - if v, ok := oaFormFirst(m, "title", "processTitle", "process_title", "name"); ok { - row["title"] = v - } - if v, ok := oaFormFirst(m, "status", "result", "processResult"); ok { - row["status"] = v - } - if v, ok := oaFormFirst(m, "createTime", "create_time", "gmtCreate", "createdTime"); ok { - row["createTime"] = v - } - return row -} - -// ListSubmitted — 获取当前用户已发起的审批单列表 (get_submitted_instances) -var ListSubmitted = shortcut.Shortcut{ - Service: "oa", - Command: "+list-submitted", - Product: "oa", - Description: "获取当前用户已发起的审批单列表", - Intent: "当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_submitted", - CanonicalPath: "oa.shortcut_list_submitted", - CLIPath: "oa +list-submitted", - PrimaryCLIPath: "oa +list-submitted", +func oaNumberedInstanceShortcut(command, tool, description, intent string) shortcut.Shortcut { + declaration := shortcut.Shortcut{ + Service: "oa", Command: command, Product: "oa", + Description: description, Intent: intent, Risk: shortcut.RiskRead, + Safety: oaReadSafety(), OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract(command, description, intent, true, + oaCollectionResult("instances", description), oaPagePagination("page"), + []contract.ParamDecl{{Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}, {Name: "query", Property: "query"}}, + "dws oa "+command+" --page 1 --limit 20"), + Flags: []shortcut.Flag{ + {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码;--page 必须大于 0"}, + {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小;--limit 必须在 1-100"}, + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, }, - Description: "获取当前用户已发起的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取当前用户已发起的审批单列表", - UseWhen: []string{"当你想查看自己提交发起的审批单及其审批进度(如某笔报销/请假审到哪一步)时使用;按页码/关键字分页返回我发起的审批单,可据此决定是否 +revoke 撤销或催办。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-submitted --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-submitted --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + Tips: []string{"dws oa " + command + " --page 1 --limit 20"}, + } + declaration.Validate = func(rt *shortcut.RuntimeContext) error { + page, err := parseOAStringPage(rt, "page", 1) if err != nil { return err } - instances := listSubmittedProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listSubmittedProject reshapes the raw get_submitted_instances response into -// the same clean {processInstanceId, title, status, createTime} approval list -// as +list-executed, so both instance listings project identically. It reuses -// the shared oaInstance* defensive probes, tolerating response-shape and -// key-spelling drift. -func listSubmittedProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// ListCc — 获取抄送当前用户的审批单列表 (get_noticed_instances) -var ListCc = shortcut.Shortcut{ - Service: "oa", - Command: "+list-cc", - Product: "oa", - Description: "获取抄送当前用户的审批单列表", - Intent: "当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_list_cc", - CanonicalPath: "oa.shortcut_list_cc", - CLIPath: "oa +list-cc", - PrimaryCLIPath: "oa +list-cc", - }, - Description: "获取抄送当前用户的审批单列表", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "获取抄送当前用户的审批单列表", - UseWhen: []string{"当你想查看抄送给自己、需要知悉但无需审批的单子时使用;按页码/关键字分页返回抄送我的审批单列表,适合了解与自己相关但不用自己动手处理的审批动态。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws oa +list-cc --limit 20 --page 1 --query 报销"}, - }, - }, - Flags: []shortcut.Flag{ - {Name: "page", Type: shortcut.FlagString, Default: "1", Desc: "分页页码"}, - {Name: "limit", Type: shortcut.FlagString, Default: "20", Desc: "每页大小"}, - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索"}, - }, - Tips: []string{`dws oa +list-cc --limit 20 --page 1 --query 报销`}, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "pageNumber": rt.Str("page"), - "pageSize": rt.Str("limit"), - } - if rt.Changed("query") { - params["query"] = rt.Str("query") - } - data, err := rt.CallMCPData("oa", "get_noticed_instances", params) + limit, err := parseOAStringPage(rt, "limit", 20) if err != nil { return err } - instances := listCcProject(data) - return rt.Output(map[string]any{"count": len(instances), "instances": instances}) - }, -} - -// listCcProject reshapes the raw get_noticed_instances response into the same -// clean {processInstanceId, title, status, createTime} approval list as the -// other instance listings, so all approval-instance listings project -// identically. It reuses the shared oaInstance* defensive probes, tolerating -// response-shape and key-spelling drift. -func listCcProject(data map[string]any) []map[string]any { - raw := oaInstanceResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - if row := oaInstanceProjectItem(m); len(row) > 0 { - out = append(out, row) - } + return validateOAPage(page, limit) } - return out + declaration.Execute = func(rt *shortcut.RuntimeContext) error { + page, _ := parseOAStringPage(rt, "page", 1) + params := map[string]any{"pageNumber": rt.Str("page"), "pageSize": rt.Str("limit")} + if query := strings.TrimSpace(rt.Str("query")); query != "" { + params["query"] = query + } + return oaInstancePage(rt, tool, params, page) + } + return declaration } -// RedirectTask — 转交审批任务给其他人 (redirect_task) -// Comment — 对审批实例添加评论 (dingflow_comments) -// CcNotice — 对审批实例进行抄送 (oa_cc_noticer) -// AppendTask — 对审批任务进行加签 (append_task) -// RevertActivities — 获取审批任务可回退的节点信息 (get_inst_revert_activities) -// RevertTask — 退回审批任务到指定节点 (revert_task) +var ListExecuted = oaNumberedInstanceShortcut( + "+list-executed", "get_done_tasks", "获取当前用户已经处理过的审批单列表", + "需要回顾当前用户已同意或拒绝过的审批实例时使用;与待办、已发起和抄送列表分开。", +) + +var ListSubmitted = oaNumberedInstanceShortcut( + "+list-submitted", "get_submitted_instances", "获取当前用户已发起的审批单列表", + "需要查看当前用户发起的审批实例和当前状态时使用;返回稳定 processInstanceId 与可续页证据。", +) + +var ListCc = oaNumberedInstanceShortcut( + "+list-cc", "get_noticed_instances", "获取抄送当前用户的审批单列表", + "需要查看抄送给当前用户的审批实例时使用;没有安全非空抄送 fixture 前不会进入公开发现。", +) + func init() { - shortcut.Register( - ListPending, - ListForms, - SearchForms, - ListExecuted, - ListSubmitted, - ListCc, - ) + shortcut.Register(ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, ListCc) } diff --git a/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..7df9f056 --- /dev/null +++ b/internal/shortcut/oa/oa_strict_cross_platform_coverage_test.go @@ -0,0 +1,728 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "reflect" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "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 oaCoverageCaller struct { + responses map[string][]string + history []string + arguments []map[string]any +} + +func (caller *oaCoverageCaller) CallTool(_ context.Context, _, tool string, arguments map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, tool) + caller.arguments = append(caller.arguments, arguments) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing OA fake response for " + tool) + } + caller.responses[tool] = queue[1:] + if queue[0] == "__ERROR__" { + return nil, errors.New("injected OA failure") + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*oaCoverageCaller) Format() string { return "json" } +func (*oaCoverageCaller) DryRun() bool { return false } +func (*oaCoverageCaller) Fields() string { return "" } +func (*oaCoverageCaller) JQ() string { return "" } + +func runOACoverage(t *testing.T, declaration shortcut.Shortcut, caller *oaCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func runOAConfirmedCoverage(t *testing.T, caller *oaCoverageCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs(append([]string{"oa", "+approve-by"}, args...)) + return root.Execute() +} + +func TestCrossPlatformCoverageOAContractsAreTypedAndUnified(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{ + ListPending, ListForms, SearchForms, ListExecuted, ListSubmitted, + ListCc, PendingApprovals, DoneApprovals, Approve, MyInitiated, + } { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract.Result", declaration.Command) + } + if declaration.Safety.Effect == "" || declaration.Safety.Confirmation == "" { + t.Errorf("%s lacks Safety", declaration.Command) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + } + if Approve.Safety.Confirmation != "user_required" || Approve.Risk != shortcut.RiskHighWrite { + t.Fatal("+approve-by must require explicit high-risk confirmation") + } + unavailable := oaContract("+fixture-unavailable", "fixture unavailable", "reviewed fixture is unavailable", false, oaCollectionResult("items", "fixture items"), nil, nil) + if unavailable.Interface == nil || unavailable.Interface.Availability != "unavailable" || unavailable.Interface.Reason != "reviewed fixture is unavailable" { + t.Fatalf("unavailable OA contract interface=%+v", unavailable.Interface) + } + for _, declaration := range []shortcut.Shortcut{ListPending, ListForms, ListExecuted, ListSubmitted, ListCc, MyInitiated} { + if declaration.Contract.Pagination == nil { + t.Errorf("%s lacks Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageOAStrictResponseMatrix(t *testing.T) { + validEmpty := map[string]any{"success": "true", "result": map[string]any{"values": []any{}}} + items, err := oaProjectInstances(validEmpty, "oa/test", "result.values") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty must succeed: items=%v err=%v", items, err) + } + for name, fixture := range map[string]map[string]any{ + "empty": {}, + "missing success": {"result": map[string]any{"values": []any{}}}, + "false success": {"success": false, "result": map[string]any{"values": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{}}, + "wrong collection": {"success": true, "result": map[string]any{"values": map[string]any{}}}, + "bad item": {"success": true, "result": map[string]any{"values": []any{"bad"}}}, + "missing identity": {"success": true, "result": map[string]any{"values": []any{map[string]any{"title": "fixture"}}}}, + } { + if projected, err := oaProjectInstances(fixture, "oa/test", "result.values"); err == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + forms := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processCode": "p", "processName": "fixture"}}}} + if projected, err := oaProjectForms(forms, "oa/forms", "result.processCodeList"); err != nil || len(projected) != 1 { + t.Fatalf("strict forms projection=%#v err=%v", projected, err) + } + badForm := map[string]any{"success": true, "result": map[string]any{"processCodeList": []any{map[string]any{"processName": "fixture"}}}} + if projected, err := oaProjectForms(badForm, "oa/forms", "result.processCodeList"); err == nil { + t.Fatalf("form without processCode returned success: %#v", projected) + } + search := map[string]any{"success": true, "result": []any{map[string]any{"processCode": "p", "processName": "fixture"}}} + if projected, err := oaProjectForms(search, "oa/search", "result"); err != nil || len(projected) != 1 { + t.Fatalf("strict search projection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageOAPaginationFailsClosed(t *testing.T) { + if _, err := oaHasMorePage(map[string]any{}, "oa/page", 1); err == nil { + t.Fatal("numbered page without hasMore was accepted") + } + if _, err := oaHasMorePage(map[string]any{"hasMore": "false"}, "oa/page", 1); err == nil { + t.Fatal("wrong hasMore type was accepted") + } + page, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 2) + if err != nil || !page.HasMore || page.Next != "3" { + t.Fatalf("numbered continuation=%+v err=%v", page, err) + } + if _, err := oaCursorPage(map[string]any{}, "oa/cursor", 0); err == nil { + t.Fatal("cursor response without pagination was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true}, "oa/cursor", 0); err == nil { + t.Fatal("hasMore without nextCursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 1); err == nil { + t.Fatal("stalled cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(1)}, "oa/cursor", 2); err == nil { + t.Fatal("backward cursor was accepted") + } + if _, err := oaCursorPage(map[string]any{"hasMore": true, "nextCursor": "not-a-number"}, "oa/cursor", 1); err == nil { + t.Fatal("non-integer cursor was accepted") + } + page, err = oaCursorPage(map[string]any{"hasMore": true, "nextCursor": float64(2)}, "oa/cursor", 1) + if err != nil || page.Next != "2" { + t.Fatalf("cursor continuation=%+v err=%v", page, err) + } +} + +func TestCrossPlatformCoverageMyInitiatedNeverFallsBackToRawResponse(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "get_submitted_instances": {`{"success":"true","result":{"values":[],"hasMore":false}}`}, + }} + cmd, err := runOACoverage(t, MyInitiated, caller, "--page", "1", "--limit", "20") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + code, emitted, err := output.EmitStoredResult(cmd) + if err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + if _, raw := envelope.Data["result"]; raw { + t.Fatalf("raw result leaked: %s", stdout.String()) + } + initiated, ok := envelope.Data["initiated"].([]any) + if !ok || len(initiated) != 0 || envelope.Data["complete"] != true { + t.Fatalf("strict empty initiated payload=%#v", envelope.Data) + } +} + +func TestCrossPlatformCoverageOAApproveConfirmationAndReadback(t *testing.T) { + unconfirmed := &oaCoverageCaller{responses: map[string][]string{}} + helpers.InitDepsForTest(t, unconfirmed) + root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + root.PersistentFlags().Bool("yes", false, "") + root.PersistentFlags().Bool("dry-run", false, "") + root.PersistentFlags().String("format", "json", "") + ctx, _ := output.WithResultStore(context.Background()) + root.SetContext(ctx) + root.SetOut(io.Discard) + root.SetErr(io.Discard) + service := &cobra.Command{Use: "oa"} + service.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + root.AddCommand(service) + root.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture"}) + if err := root.Execute(); err == nil { + t.Fatal("unconfirmed approval unexpectedly succeeded") + } + if len(unconfirmed.history) != 0 { + t.Fatalf("unconfirmed approval made calls: %v", unconfirmed.history) + } + + confirmed := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {`{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"instance-1","title":"fixture"}]}}`}, + "list_pending_tasks": { + `{"success":true,"result":{"taskIdList":[{"taskId":1}]}}`, + `{"success":true,"result":{"taskIdList":[]}}`, + }, + "approve_processInstance": {`{"success":true,"result":{"accepted":true}}`}, + }} + helpers.InitDepsForTest(t, confirmed) + confirmedRoot := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true} + confirmedRoot.PersistentFlags().Bool("yes", false, "") + confirmedRoot.PersistentFlags().Bool("dry-run", false, "") + confirmedRoot.PersistentFlags().String("format", "json", "") + confirmedCtx, _ := output.WithResultStore(context.Background()) + confirmedRoot.SetContext(confirmedCtx) + confirmedRoot.SetOut(io.Discard) + confirmedRoot.SetErr(io.Discard) + confirmedService := &cobra.Command{Use: "oa"} + confirmedService.AddCommand(corecmd.New(shortcut.FromShortcut(Approve))) + confirmedRoot.AddCommand(confirmedService) + confirmedRoot.SetArgs([]string{"oa", "+approve-by", "--keyword", "fixture", "--yes"}) + if err := confirmedRoot.Execute(); err != nil { + t.Fatal(err) + } + if got := strings.Join(confirmed.history, ","); got != "list_pending_approvals,list_pending_tasks,approve_processInstance,list_pending_tasks" { + t.Fatalf("call history=%s", got) + } + if len(confirmed.arguments) != 4 || confirmed.arguments[2]["processInstanceId"] != "instance-1" || confirmed.arguments[3]["processInstanceId"] != "instance-1" { + t.Fatalf("identity was not preserved: %#v", confirmed.arguments) + } +} + +func TestCrossPlatformCoverageOACommonHelperBranches(t *testing.T) { + if oaObjectResult("fixture") == nil || oaPostWriteError("oa/test", "fixture", "fixture") == nil { + t.Fatal("typed object result and post-write error must be constructed") + } + + for name, fixture := range map[string]map[string]any{ + "false with backend message": {"success": false, "errorMessage": "fixture"}, + "false without message": {"success": false}, + "null success": {"success": nil}, + "wrong success string": {"success": "TRUE"}, + } { + if err := oaRequireSuccess(fixture, "oa/test"); err == nil { + t.Errorf("%s was accepted", name) + } + } + + if _, ok := oaLookup(map[string]any{"result": "bad"}, "result.values"); ok { + t.Fatal("lookup traversed a non-object") + } + if _, ok := oaLookup(map[string]any{"result": map[string]any{}}, "result.values"); ok { + t.Fatal("lookup accepted a missing segment") + } + for name, fixture := range map[string]map[string]any{ + "nil object": {"success": true, "result": nil}, + "wrong object": {"success": true, "result": "bad"}, + "empty object": {"success": true, "result": map[string]any{}}, + } { + if value, err := oaRequireObject(fixture, "oa/test", "result"); err == nil { + t.Errorf("%s returned object %#v", name, value) + } + } + if value, err := oaRequireObject(map[string]any{"success": false}, "oa/test", "result"); err == nil { + t.Fatalf("failed envelope returned object %#v", value) + } + object, err := oaRequireObject(map[string]any{"success": true, "result": map[string]any{"id": "fixture"}}, "oa/test", "result") + if err != nil || object["id"] != "fixture" { + t.Fatalf("valid object=%#v err=%v", object, err) + } + + for name, value := range map[string]any{ + "string": " fixture ", + "json number": json.Number("42"), + "float": float64(42), + "int": int(42), + "int64": int64(42), + "unsupported": true, + } { + got := oaScalarString(value) + if name == "unsupported" && got != "" { + t.Errorf("unsupported scalar=%q", got) + } + if name != "unsupported" && got == "" { + t.Errorf("%s scalar was empty", name) + } + } + + identityFixture := map[string]any{"id": int64(42)} + if err := oaRequireIdentity(identityFixture, "oa/test", "42", "id"); err != nil { + t.Fatal(err) + } + if err := oaRequireIdentity(map[string]any{}, "oa/test", "", "id"); err == nil { + t.Fatal("missing identity was accepted") + } + if err := oaRequireIdentity(identityFixture, "oa/test", "43", "id"); err == nil { + t.Fatal("mismatched identity was accepted") + } + + formsFixture := map[string]any{"success": true, "result": []any{map[string]any{ + "processCode": "p", "name": "fixture", "iconUrl": "icon", + }}} + forms, err := oaProjectForms(formsFixture, "oa/forms", "result") + if err != nil || forms[0]["iconUrl"] != "icon" { + t.Fatalf("fallback form fields=%#v err=%v", forms, err) + } + + instanceFixture := map[string]any{"success": true, "result": map[string]any{"values": []any{map[string]any{ + "processInstanceId": "i", "processInstanceTitle": "title", "status": json.Number("2"), + "businessId": 7, "originatorUserName": "owner", "createTime": int64(9), + }}}} + instances, err := oaProjectInstances(instanceFixture, "oa/instances", "result.values") + if err != nil { + t.Fatal(err) + } + wantInstance := map[string]any{"processInstanceId": "i", "title": "title", "status": "2", "businessId": "7", "originatorName": "owner", "createTime": int64(9)} + if !reflect.DeepEqual(instances[0], wantInstance) { + t.Fatalf("projected instance=%#v want=%#v", instances[0], wantInstance) + } + + for name, fixture := range map[string]map[string]any{ + "task projection error": {"success": true, "result": map[string]any{}}, + "missing task id": {"success": true, "result": map[string]any{"taskIdList": []any{map[string]any{"x": 1}}}}, + "duplicate task id": {"success": true, "result": map[string]any{"taskIdList": []any{ + map[string]any{"taskId": 1}, map[string]any{"taskId": 1}, + }}}, + } { + if tasks, err := oaProjectTasks(fixture, "oa/tasks"); err == nil { + t.Errorf("%s returned %#v", name, tasks) + } + } + + for name, fixture := range map[string]map[string]any{ + "wrong cursor hasMore": {"hasMore": "true"}, + "terminal conflict": {"hasMore": false, "nextCursor": 1}, + } { + if page, err := oaCursorPage(fixture, "oa/cursor", 0); err == nil { + t.Errorf("%s returned %+v", name, page) + } + } + terminal, err := oaCursorPage(map[string]any{"hasMore": false}, "oa/cursor", 0) + if err != nil || !terminal.Known || terminal.HasMore || terminal.Next != "" { + t.Fatalf("terminal cursor=%+v err=%v", terminal, err) + } + if _, err := oaHasMorePage(map[string]any{"hasMore": true}, "oa/page", 0); err == nil { + t.Fatal("continuation from invalid current page was accepted") + } + terminal, err = oaHasMorePage(map[string]any{"hasMore": false}, "oa/page", 1) + if err != nil || !terminal.Known || terminal.HasMore { + t.Fatalf("terminal numbered page=%+v err=%v", terminal, err) + } + + if err := validateOAPage(0, 1); err == nil { + t.Fatal("zero page was accepted") + } + if err := validateOAPage(1, 0); err == nil { + t.Fatal("zero limit was accepted") + } + if err := validateOAPage(1, 101); err == nil { + t.Fatal("oversized limit was accepted") + } +} + +func TestCrossPlatformCoverageOAOutputBranches(t *testing.T) { + legacyPage := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-page", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", []map[string]any{{"id": "1"}}, oaPageEvidence{Known: true, HasMore: true, Next: "2"}) + }, + } + _, err := runOACoverage(t, legacyPage, &oaCoverageCaller{responses: map[string][]string{}}) + if err != nil { + t.Fatal(err) + } + unknownPage := legacyPage + unknownPage.Command = "+coverage-unknown-page" + unknownPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{}) + } + if _, err := runOACoverage(t, unknownPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unknown pagination was accepted") + } + + legacyComplete := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-legacy-complete", Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Execute: func(rt *shortcut.RuntimeContext) error { return outputOACompleteCollection(rt, "items", nil) }, + } + if _, err := runOACoverage(t, legacyComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + unifiedComplete := legacyComplete + unifiedComplete.Command = "+coverage-unified-complete" + unifiedComplete.OutputRollout = output.RolloutUnifiedActive + if _, err := runOACoverage(t, unifiedComplete, &oaCoverageCaller{responses: map[string][]string{}}); err != nil { + t.Fatal(err) + } + + badUnifiedPage := legacyPage + badUnifiedPage.Command = "+coverage-unified-bad-page" + badUnifiedPage.OutputRollout = output.RolloutUnifiedActive + badUnifiedPage.Execute = func(rt *shortcut.RuntimeContext) error { + return outputOAPage(rt, "items", nil, oaPageEvidence{Known: true, HasMore: true}) + } + if _, err := runOACoverage(t, badUnifiedPage, &oaCoverageCaller{responses: map[string][]string{}}); err == nil { + t.Fatal("unified continuation without token was accepted") + } +} + +func TestCrossPlatformCoverageOAReadShortcutBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + + validPage := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + for name, args := range map[string][]string{ + "bad interval": {"--start", "2", "--end", "1"}, + "bad page": {"--start", "1", "--end", "2", "--page", "bad"}, + "bad limit": {"--start", "1", "--end", "2", "--limit", "bad"}, + "zero page": {"--start", "1", "--end", "2", "--page", "0"}, + } { + t.Run("list-pending validate "+name, func(t *testing.T) { + caller := expectError(t, ListPending, map[string][]string{}, args...) + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } + t.Run("list-pending call failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {"__ERROR__"}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending projection failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending null result fails closed", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending pagination failure", func(t *testing.T) { + expectError(t, ListPending, map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}, "--start", "1", "--end", "2") + }) + t.Run("list-pending full params", func(t *testing.T) { + caller := expectSuccess(t, ListPending, map[string][]string{"list_pending_approvals": {validPage}}, + "--start", "1", "--end", "2", "--page", "2", "--limit", "3", "--query", "fixture") + want := map[string]any{"starTime": 1, "endTime": 2, "pageNum": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("list pending params=%#v want=%#v", caller.arguments[0], want) + } + }) + + for name, args := range map[string][]string{ + "negative cursor": {"--cursor", "-1"}, + "zero limit": {"--limit", "0"}, + "oversized limit": {"--limit", "101"}, + } { + t.Run("list-forms validate "+name, func(t *testing.T) { + expectError(t, ListForms, map[string][]string{}, args...) + }) + } + t.Run("list-forms call failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {"__ERROR__"}}) + }) + t.Run("list-forms projection failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("list-forms null result fails closed", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":null}`}}) + }) + t.Run("list-forms pagination failure", func(t *testing.T) { + expectError(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"processCodeList":[]}}`}}) + }) + t.Run("list-forms success", func(t *testing.T) { + expectSuccess(t, ListForms, map[string][]string{"list_user_visible_process": {`{"success":true,"result":{"hasMore":true,"nextCursor":2,"processCodeList":[{"processCode":"p","processName":"fixture"}]}}`}}, "--cursor", "1", "--limit", "2") + }) + + t.Run("search empty query", func(t *testing.T) { + caller := expectError(t, SearchForms, map[string][]string{}, "--query", " ") + if len(caller.history) != 0 { + t.Fatalf("blank query made calls: %v", caller.history) + } + }) + t.Run("search call failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {"__ERROR__"}}, "--query", "fixture") + }) + t.Run("search projection failure", func(t *testing.T) { + expectError(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":{}}`}}, "--query", "fixture") + }) + t.Run("search known nonempty", func(t *testing.T) { + caller := expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[{"processCode":"p","processName":"fixture"}]}`}}, "--query", " fixture ") + if got := caller.arguments[0]["query"]; got != "fixture" { + t.Fatalf("normalized query=%#v, want fixture", got) + } + }) + t.Run("search legitimate empty", func(t *testing.T) { + expectSuccess(t, SearchForms, map[string][]string{"search_form": {`{"success":true,"result":[]}`}}, "--query", "guaranteed-zero-fixture") + }) + + for _, declaration := range []shortcut.Shortcut{ListExecuted, ListSubmitted, ListCc} { + declaration := declaration + tool := map[string]string{"+list-executed": "get_done_tasks", "+list-submitted": "get_submitted_instances", "+list-cc": "get_noticed_instances"}[declaration.Command] + t.Run(declaration.Command+" invalid page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--page", "bad") + }) + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + expectError(t, declaration, map[string][]string{}, "--limit", "bad") + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" full params", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {validPage}}, "--page", "2", "--limit", "3", "--query", " fixture ") + want := map[string]any{"pageNumber": "2", "pageSize": "3", "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("params=%#v want=%#v", caller.arguments[0], want) + } + }) + } +} + +func TestCrossPlatformCoverageOACompatibilityReadBranches(t *testing.T) { + expectError := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err == nil { + t.Fatalf("%s unexpectedly succeeded with args %v", declaration.Command, args) + } + return caller + } + expectSuccess := func(t *testing.T, declaration shortcut.Shortcut, responses map[string][]string, args ...string) *oaCoverageCaller { + t.Helper() + caller := &oaCoverageCaller{responses: responses} + if _, err := runOACoverage(t, declaration, caller, args...); err != nil { + t.Fatalf("%s failed with args %v: %v", declaration.Command, args, err) + } + return caller + } + terminal := `{"success":true,"result":{"hasMore":false,"values":[{"processInstanceId":"i","title":"fixture"}]}}` + + for _, declaration := range []shortcut.Shortcut{PendingApprovals, DoneApprovals} { + declaration := declaration + tool := map[string]string{"+pending": "list_pending_approvals", "+done-approvals": "get_done_tasks"}[declaration.Command] + t.Run(declaration.Command+" invalid limit", func(t *testing.T) { + caller := expectError(t, declaration, map[string][]string{}, "--limit", "0") + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + t.Run(declaration.Command+" call failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {"__ERROR__"}}) + }) + t.Run(declaration.Command+" projection failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run(declaration.Command+" null result fails closed", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":null}`}}) + }) + t.Run(declaration.Command+" pagination failure", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run(declaration.Command+" refuses incomplete first page", func(t *testing.T) { + expectError(t, declaration, map[string][]string{tool: {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}) + }) + t.Run(declaration.Command+" default limit", func(t *testing.T) { + expectSuccess(t, declaration, map[string][]string{tool: {terminal}}) + }) + t.Run(declaration.Command+" explicit limit", func(t *testing.T) { + caller := expectSuccess(t, declaration, map[string][]string{tool: {terminal}}, "--limit", "3") + if caller.arguments[0]["pageSize"] != float64(3) { + t.Fatalf("explicit limit params=%#v", caller.arguments[0]) + } + }) + } + + for name, args := range map[string][]string{ + "bad page": {"--page", "0"}, + "bad limit": {"--limit", "101"}, + } { + t.Run("my-initiated validate "+name, func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{}, args...) + }) + } + t.Run("my-initiated call failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {"__ERROR__"}}) + }) + t.Run("my-initiated projection failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":false}}`}}) + }) + t.Run("my-initiated null result fails closed", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":null}`}}) + }) + t.Run("my-initiated pagination failure", func(t *testing.T) { + expectError(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"values":[]}}`}}) + }) + t.Run("my-initiated query and continuation", func(t *testing.T) { + caller := expectSuccess(t, MyInitiated, map[string][]string{"get_submitted_instances": {`{"success":true,"result":{"hasMore":true,"values":[]}}`}}, "--query", "fixture", "--page", "2", "--limit", "3") + want := map[string]any{"pageNumber": float64(2), "pageSize": float64(3), "query": "fixture"} + if !reflect.DeepEqual(caller.arguments[0], want) { + t.Fatalf("my initiated params=%#v want=%#v", caller.arguments[0], want) + } + }) +} + +func TestCrossPlatformCoverageOARequiredFlagValidationClosures(t *testing.T) { + for _, tc := range []struct { + name string + flagName string + validate func(*shortcut.RuntimeContext) error + }{ + {name: "search forms", flagName: "query", validate: SearchForms.Validate}, + {name: "approve", flagName: "keyword", validate: Approve.Validate}, + } { + t.Run(tc.name, func(t *testing.T) { + declaration := shortcut.Shortcut{ + Service: "oa", Command: "+coverage-" + strings.ReplaceAll(tc.name, " ", "-"), Product: "oa", Description: "fixture", Risk: shortcut.RiskRead, + Flags: []shortcut.Flag{{Name: tc.flagName, Type: shortcut.FlagString}}, + Execute: tc.validate, + } + caller := &oaCoverageCaller{responses: map[string][]string{}} + if _, err := runOACoverage(t, declaration, caller); err == nil { + t.Fatal("empty required semantic value was accepted") + } + if len(caller.history) != 0 { + t.Fatalf("validation made calls: %v", caller.history) + } + }) + } +} + +func TestCrossPlatformCoverageOAApproveFailureLedger(t *testing.T) { + pending := func(values string, hasMore bool) string { + return `{"success":true,"result":{"hasMore":` + map[bool]string{true: "true", false: "false"}[hasMore] + `,"values":` + values + `}}` + } + tasks := func(values string) string { + return `{"success":true,"result":{"taskIdList":` + values + `}}` + } + validPending := pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, false) + validTasks := tasks(`[{"taskId":1}]`) + + cases := []struct { + name string + responses map[string][]string + }{ + {name: "pending call failure", responses: map[string][]string{"list_pending_approvals": {"__ERROR__"}}}, + {name: "pending projection failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"hasMore":false}}`}}}, + {name: "pending null result", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":null}`}}}, + {name: "pending pagination failure", responses: map[string][]string{"list_pending_approvals": {`{"success":true,"result":{"values":[]}}`}}}, + {name: "pending is incomplete", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"fixture"}]`, true)}}}, + {name: "zero matches", responses: map[string][]string{"list_pending_approvals": {pending(`[{"processInstanceId":"instance-1","title":"other"}]`, false)}}}, + {name: "task call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {"__ERROR__"}}}, + {name: "task projection failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {`{"success":true,"result":{}}`}}}, + {name: "task count is not one", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[]`)}}}, + {name: "task identity is not numeric", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {tasks(`[{"taskId":"not-numeric"}]`)}}}, + {name: "write call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {"__ERROR__"}}}, + {name: "write business failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":false}`}}}, + {name: "write missing success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"result":{"accepted":true}}`}}}, + {name: "write null success", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks}, "approve_processInstance": {`{"success":null}`}}}, + {name: "readback call failure", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, "__ERROR__"}, "approve_processInstance": {`{"success":true}`}}}, + {name: "readback malformed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, `{"success":true,"result":{}}`}, "approve_processInstance": {`{"success":true}`}}}, + {name: "write not observed", responses: map[string][]string{"list_pending_approvals": {validPending}, "list_pending_tasks": {validTasks, validTasks}, "approve_processInstance": {`{"success":true}`}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + caller := &oaCoverageCaller{responses: tc.responses} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--yes"); err == nil { + t.Fatalf("failure scenario succeeded; calls=%v", caller.history) + } + }) + } + + t.Run("empty keyword validates before calls", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{}} + if err := runOAConfirmedCoverage(t, caller, "--keyword", " ", "--yes"); err == nil { + t.Fatal("blank keyword succeeded") + } + if len(caller.history) != 0 { + t.Fatalf("blank keyword made calls: %v", caller.history) + } + }) + + t.Run("comment is sent only after confirmation", func(t *testing.T) { + caller := &oaCoverageCaller{responses: map[string][]string{ + "list_pending_approvals": {validPending}, + "list_pending_tasks": {validTasks, tasks(`[]`)}, + "approve_processInstance": {`{"success":true}`}, + }} + if err := runOAConfirmedCoverage(t, caller, "--keyword", "fixture", "--comment", "approved", "--yes"); err != nil { + t.Fatal(err) + } + if caller.arguments[2]["remark"] != "approved" { + t.Fatalf("write args=%#v", caller.arguments[2]) + } + }) +} diff --git a/internal/shortcut/oa/smart.go b/internal/shortcut/oa/smart.go new file mode 100644 index 00000000..0bacde28 --- /dev/null +++ b/internal/shortcut/oa/smart.go @@ -0,0 +1,272 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package oa + +import ( + "fmt" + "strconv" + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func oaFirstPageOnly(rt *shortcut.RuntimeContext, tool, collection string, params map[string]any) error { + operation := "oa/" + tool + data, err := rt.CallMCPData("oa", tool, params) + if err != nil { + return err + } + instances, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaHasMorePage(result, operation, 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError(operation, "unavailable_continuation", "兼容 Shortcut 没有页码参数且结果仍有后续页;请改用对应可分页命令") + } + return outputOACompleteCollection(rt, collection, instances) +} + +var PendingApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+pending", Product: "oa", + Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + Intent: "兼容入口:读取近三个月待处理审批的首个完整页;无法证明完整或缺少非空 fixture 时不进入 Agent 公开发现。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+pending", "只读列出待我审批的审批任务并投影为可读列表(只看不批)", + "兼容旧的待审批摘要入口;新调用优先使用支持显式时间和页码的 +list-pending。", + true, + oaCollectionResult("pending", "严格验证的待审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +pending --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +pending --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + now := time.Now() + params := map[string]any{"starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli())} + if rt.Changed("limit") { + params["pageSize"] = float64(rt.Int("limit")) + } + return oaFirstPageOnly(rt, "list_pending_approvals", "pending", params) + }, +} + +var DoneApprovals = shortcut.Shortcut{ + Service: "oa", Command: "+done-approvals", Product: "oa", + Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + Intent: "兼容入口:读取我已处理审批的首个完整页;新调用优先使用可分页的 +list-executed。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+done-approvals", "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", + "兼容旧的已处理审批摘要入口;需要翻页或搜索时使用 +list-executed。", + true, + oaCollectionResult("done", "严格验证的已处理审批摘要"), nil, + []contract.ParamDecl{{Name: "limit", Property: "limit"}}, "dws oa +done-approvals --limit 10", + ), + Flags: []shortcut.Flag{{Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)"}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100"}}, + Tips: []string{`dws oa +done-approvals --limit 10`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("limit") && (rt.Int("limit") <= 0 || rt.Int("limit") > 100) { + return apperrors.NewValidation("--limit 必须在 1 到 100 之间") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") + } + return oaFirstPageOnly(rt, "get_done_tasks", "done", map[string]any{"pageNumber": float64(1), "pageSize": float64(limit)}) + }, +} + +var MyInitiated = shortcut.Shortcut{ + Service: "oa", Command: "+my-initiated", Product: "oa", + Description: "列出我发起(提交)的审批单据", + Intent: "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + Risk: shortcut.RiskRead, + Safety: oaReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+my-initiated", "列出我发起(提交)的审批单据", + "需要兼容旧的 initiated 输出字段时使用;一般列表与分页可直接使用 +list-submitted。", + true, + oaCollectionResult("initiated", "严格验证的已发起审批实例页"), oaPagePagination("page"), + []contract.ParamDecl{{Name: "query", Property: "query"}, {Name: "page", Property: "page"}, {Name: "limit", Property: "limit"}}, + "dws oa +my-initiated --page 1 --limit 20", "dws oa +my-initiated --query 报销", + ), + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)"}, + {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1);--page 必须大于 0", Default: "1"}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20);--limit 必须在 1-100", Default: "20"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"page"}, Description: "--page 必须大于 0"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "--limit 必须在 1-100"}, + }, + Tips: []string{`dws oa +my-initiated`, `dws oa +my-initiated --query 报销`, `dws oa +my-initiated --page 2 --limit 50`}, + Validate: func(rt *shortcut.RuntimeContext) error { return validateOAPage(rt.Int("page"), rt.Int("limit")) }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "oa/get_submitted_instances" + params := map[string]any{"pageNumber": float64(rt.Int("page")), "pageSize": float64(rt.Int("limit"))} + if query := rt.Str("query"); query != "" { + params["query"] = query + } + data, err := rt.CallMCPData("oa", "get_submitted_instances", params) + if err != nil { + return err + } + items, err := oaProjectInstances(data, operation, "result.values") + if err != nil { + return err + } + result, _ := data["result"].(map[string]any) + page, err := oaHasMorePage(result, operation, rt.Int("page")) + if err != nil { + return err + } + return outputOAPage(rt, "initiated", items, page) + }, +} + +type oaApprovalMatch struct { + id string + title string +} + +func oaMatchApprovals(items []map[string]any, keyword string) []oaApprovalMatch { + needle := strings.ToLower(strings.TrimSpace(keyword)) + matches := make([]oaApprovalMatch, 0) + for _, item := range items { + id := oaIdentity(item, "processInstanceId") + title := oaFirstString(item, "title", "processInstanceTitle") + businessID := oaIdentity(item, "businessId") + if strings.Contains(strings.ToLower(title), needle) || strings.Contains(strings.ToLower(businessID), needle) || strings.EqualFold(id, needle) { + matches = append(matches, oaApprovalMatch{id: id, title: title}) + } + } + return matches +} + +var Approve = shortcut.Shortcut{ + Service: "oa", Command: "+approve-by", Product: "oa", + Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + Intent: "高风险兼容编排:完整读取并唯一匹配待办、唯一解析 taskId、确认后同意,再精确读回该 taskId 已不在待处理集合;没有安全 fixture 前不进入 Agent 公开发现。", + Risk: shortcut.RiskHighWrite, + Safety: oaWriteSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: oaContract( + "+approve-by", "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", + "仅在用户明确确认同意、关键词唯一定位实例且任务读回可验证时使用;任何歧义、分页不完整或读回失败都会阻止成功。", + true, + oaWriteResult("同意审批并通过精确任务读回验证"), nil, + []contract.ParamDecl{{Name: "keyword", Property: "query"}, {Name: "comment", Property: "remark"}}, + "dws oa +approve-by --keyword 报销", + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, + {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"keyword"}, Description: "--keyword 去除空白后不能为空,且必须唯一匹配完整待办集合中的一条实例"}}, + Tips: []string{`dws oa +approve-by --keyword 报销`, `dws oa +approve-by --keyword 出差单 --comment "同意"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("keyword")) + if keyword == "" { + return apperrors.NewValidation("--keyword 不能为空") + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + keyword := strings.TrimSpace(rt.Str("keyword")) + now := time.Now() + pending, err := rt.CallMCPData("oa", "list_pending_approvals", map[string]any{ + "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), "endTime": float64(now.UnixMilli()), "query": keyword, + }) + if err != nil { + return err + } + items, err := oaProjectInstances(pending, "oa/list_pending_approvals", "result.values") + if err != nil { + return err + } + result, _ := pending["result"].(map[string]any) + page, err := oaHasMorePage(result, "oa/list_pending_approvals", 1) + if err != nil { + return err + } + if page.HasMore { + return oaResponseError("oa/list_pending_approvals", "ambiguous_incomplete_search", "待办搜索仍有后续页,无法证明关键词唯一") + } + matches := oaMatchApprovals(items, keyword) + if len(matches) != 1 { + return apperrors.NewValidation(fmt.Sprintf("关键词必须唯一匹配一条待审批实例,当前匹配 %d 条", len(matches))) + } + instanceID := matches[0].id + tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return err + } + tasks, err := oaProjectTasks(tasksData, "oa/list_pending_tasks") + if err != nil { + return err + } + if len(tasks) != 1 { + return apperrors.NewValidation(fmt.Sprintf("审批实例必须唯一对应一条待处理任务,当前为 %d 条", len(tasks))) + } + taskID := tasks[0]["taskId"].(string) + numericTaskID, err := strconv.ParseFloat(taskID, 64) + if err != nil { + return oaResponseError("oa/list_pending_tasks", "malformed_task_identity", "taskId 不是可写入审批接口的数字") + } + writeArgs := map[string]any{"processInstanceId": instanceID, "taskId": numericTaskID} + if comment := rt.Str("comment"); comment != "" { + writeArgs["remark"] = comment + } + receipt, err := rt.CallMCPWriteDataStrict("oa", "approve_processInstance", writeArgs) + if err != nil { + return err + } + if err := oaRequireSuccess(receipt, "oa/approve_processInstance"); err != nil { + return err + } + readback, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{"processInstanceId": instanceID}) + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_failed", "审批写入后无法读取任务状态;远端效果未知") + } + remaining, err := oaProjectTasks(readback, "oa/list_pending_tasks") + if err != nil { + return oaPostWriteError("oa/list_pending_tasks", "readback_malformed", "审批写入后的任务读回无法验证;远端效果未知") + } + for _, task := range remaining { + if task["taskId"] == taskID { + return oaPostWriteError("oa/list_pending_tasks", "write_not_observed", "审批任务写后读回仍处于待处理集合") + } + } + return rt.Output(map[string]any{"processInstanceId": instanceID, "taskId": taskID, "verified": true}) + }, +} + +func init() { + shortcut.Register(PendingApprovals, DoneApprovals, Approve, MyInitiated) +} diff --git a/internal/shortcut/public_catalog_consistency_test.go b/internal/shortcut/public_catalog_consistency_test.go new file mode 100644 index 00000000..afd3a2a1 --- /dev/null +++ b/internal/shortcut/public_catalog_consistency_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package shortcut + +import ( + "encoding/json" + "os" + "path/filepath" + "runtime" + "testing" +) + +func TestCrossPlatformCoveragePublicCatalogArtifactsStayConsistent(t *testing.T) { + _, testFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("locate public catalog test file") + } + catalogPath := filepath.Join(filepath.Dir(testFile), "..", "..", "docs", "shortcut-public-catalog.json") + raw, err := os.ReadFile(catalogPath) + if err != nil { + t.Fatalf("read public catalog: %v", err) + } + var catalog struct { + Count int `json:"count"` + Results []struct { + Service string `json:"service"` + Command string `json:"command"` + } `json:"results"` + } + if err := json.Unmarshal(raw, &catalog); err != nil { + t.Fatalf("decode public catalog: %v", err) + } + if catalog.Count != len(catalog.Results) { + t.Fatalf("public catalog count = %d, results = %d", catalog.Count, len(catalog.Results)) + } + if catalog.Count != len(publicShortcutCatalog) { + t.Fatalf("public catalog count = %d, generated Go keys = %d", catalog.Count, len(publicShortcutCatalog)) + } + seen := make(map[string]struct{}, len(catalog.Results)) + for i, row := range catalog.Results { + key := publicCatalogKey(row.Service, row.Command) + if row.Service == "" || row.Command == "" { + t.Fatalf("public catalog result %d has an empty identity", i) + } + if _, duplicate := seen[key]; duplicate { + t.Fatalf("public catalog result %d duplicates %q", i, key) + } + seen[key] = struct{}{} + if _, generated := publicShortcutCatalog[key]; !generated { + t.Fatalf("public catalog result %d is missing from generated Go keys: %q", i, key) + } + } + for key := range publicShortcutCatalog { + if _, documented := seen[key]; !documented { + t.Fatalf("generated Go key is missing from public catalog JSON: %q", key) + } + } +} diff --git a/internal/shortcut/public_catalog_generated.go b/internal/shortcut/public_catalog_generated.go index 0aba731a..1d81b103 100644 --- a/internal/shortcut/public_catalog_generated.go +++ b/internal/shortcut/public_catalog_generated.go @@ -101,23 +101,12 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "aitable\u0000+workflow-list": {}, "attendance\u0000+check-record": {}, "attendance\u0000+check-result": {}, - "attendance\u0000+get-adjustment-rule": {}, "attendance\u0000+get-approve-template": {}, - "attendance\u0000+get-checkin-record": {}, - "attendance\u0000+get-leave-records": {}, "attendance\u0000+get-overtime-rule": {}, - "attendance\u0000+get-schedule": {}, - "attendance\u0000+get-self-setting": {}, - "attendance\u0000+get-summary": {}, "attendance\u0000+list-approve": {}, - "attendance\u0000+list-leave-types": {}, - "attendance\u0000+my-attendance": {}, - "attendance\u0000+query-report-data": {}, "attendance\u0000+search-adjustment-rule": {}, "attendance\u0000+search-class": {}, - "attendance\u0000+search-group": {}, "attendance\u0000+search-overtime-rule": {}, - "attendance\u0000+this-month": {}, "calendar\u0000+agenda": {}, "calendar\u0000+attendee-list": {}, "calendar\u0000+book": {}, @@ -276,10 +265,7 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "devapp\u0000+version-status": {}, "devapp\u0000+webapp-config": {}, "devapp\u0000+webapp-get": {}, - "ding\u0000+list": {}, - "ding\u0000+recall-personal": {}, "ding\u0000+receiver-status": {}, - "ding\u0000+send-personal": {}, "doc\u0000+access-change": {}, "doc\u0000+access-grant": {}, "doc\u0000+access-revoke": {}, @@ -353,15 +339,13 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "drive\u0000+version-get": {}, "drive\u0000+version-history": {}, "drive\u0000+version-revert": {}, - "mail\u0000+contact-list": {}, "mail\u0000+find-mail-user": {}, "mail\u0000+folder-list": {}, - "mail\u0000+recent-mail": {}, + "mail\u0000+message": {}, + "mail\u0000+messages": {}, "mail\u0000+search-mail": {}, - "mail\u0000+tag-list": {}, - "mail\u0000+template-list": {}, - "mail\u0000+thread-list": {}, - "mail\u0000+unread-mail": {}, + "mail\u0000+thread": {}, + "mail\u0000+triage": {}, "mail\u0000+user-search": {}, "minutes\u0000+action-items": {}, "minutes\u0000+apply-permission": {}, @@ -390,15 +374,11 @@ func generatedPublicShortcutCatalog() map[string]struct{} { "minutes\u0000+update": {}, "minutes\u0000+upload": {}, "minutes\u0000+upload-and-analyze": {}, - "oa\u0000+list-cc": {}, - "oa\u0000+list-executed": {}, - "oa\u0000+list-forms": {}, - "oa\u0000+list-pending": {}, - "oa\u0000+list-submitted": {}, - "oa\u0000+my-initiated": {}, "oa\u0000+search-forms": {}, "report\u0000+inbox-list": {}, "report\u0000+outbox-list": {}, + "report\u0000+report-latest": {}, + "report\u0000+template-search": {}, "sheet\u0000+list-sheets": {}, "sheet\u0000+read": {}, "todo\u0000+assign": {}, diff --git a/internal/shortcut/report/common.go b/internal/shortcut/report/common.go new file mode 100644 index 00000000..2bac4e58 --- /dev/null +++ b/internal/shortcut/report/common.go @@ -0,0 +1,477 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "encoding/json" + "fmt" + "math" + "strconv" + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +const reportCompositeReason = "Reviewed Report Shortcut composite: the executable CLI owns strict business-success validation, exact collection paths, stable report/template identities, truthful pagination, local filtering, output projection, and multi-step detail verification." + +type reportPageEvidence struct { + HasMore bool + Next string +} + +func reportReadSafety() contract.SafetySpec { + return contract.SafetySpec{Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent"} +} + +func reportCollectionResult(collection, description string) *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(fmt.Sprintf( + `{"type":"object","description":%q,"properties":{"count":{"type":"integer","description":"当前页严格验证的日志数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"具有稳定 reportId 的日志摘要","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"creatorUserId":{"type":"string","description":"日志创建人稳定用户 ID"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"}},"required":["reportId"],"additionalProperties":false}},"complete":{"type":"boolean","description":"服务端分页证据是否证明当前查询已结束"}},"required":["count",%q,"complete"],"additionalProperties":false}`, + description, collection, description, collection, + )), + SensitivePaths: []string{collection + ".templateName", collection + ".creatorName", collection + ".creatorUserId"}, + } +} + +func reportTemplateSearchResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的日志模板搜索结果","properties":{"count":{"type":"integer","description":"匹配模板数量"},"templates":{"type":"array","description":"名称匹配且具有稳定 ID 的模板","items":{"type":"object","description":"一条日志模板摘要","properties":{"templateId":{"type":"string","description":"稳定模板 ID"},"name":{"type":"string","description":"模板名称"},"lastModifiedTime":{"type":"integer","description":"模板最后修改时间毫秒值"}},"required":["templateId","name"],"additionalProperties":false}}},"required":["count","templates"],"additionalProperties":false}`), + SensitivePaths: []string{"templates.name"}, + } +} + +func reportLatestResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格验证的最近日志详情","properties":{"report":{"type":"object","description":"稳定身份匹配的日志详情","properties":{"reportId":{"type":"string","description":"稳定日志 ID"},"title":{"type":"string","description":"日志标题"},"templateName":{"type":"string","description":"日志模板名称"},"creatorName":{"type":"string","description":"日志创建人显示名"},"createTime":{"type":"integer","description":"日志创建时间毫秒值"},"modifiedTime":{"type":"integer","description":"日志修改时间毫秒值"},"fields":{"type":"array","description":"严格验证的日志字段","items":{"type":"object","description":"日志字段值","properties":{"key":{"type":"string","description":"字段名称"},"value":{"type":"string","description":"字段内容"},"sort":{"type":"integer","description":"字段排序值"},"type":{"type":"integer","description":"字段类型码"}},"required":["key","value","sort","type"],"additionalProperties":false}}},"required":["reportId","fields"],"additionalProperties":false}},"required":["report"],"additionalProperties":false}`), + SensitivePaths: []string{"report.title", "report.templateName", "report.creatorName", "report.fields.value"}, + } +} + +func reportPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func reportContract(command, description, intent string, result *contract.ResultSpec, pagination *contract.PaginationSpec, params []contract.ParamDecl, examples ...string) corecmd.ContractDecl { + name := "shortcut_" + strings.ReplaceAll(strings.TrimPrefix(command, "+"), "-", "_") + cliPath := "report " + command + return corecmd.ContractDecl{ + Description: description, + Result: result, + Pagination: pagination, + Parameters: params, + Identity: contract.ToolIdentitySpec{ + ProductID: "report", Name: name, CanonicalPath: "report." + name, + CLIPath: cliPath, PrimaryCLIPath: cliPath, + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: reportCompositeReason}, + Selection: contract.SelectionSpec{ + AgentSummary: description, + UseWhen: []string{intent}, + AvoidWhen: []string{"钉钉在线文档使用 doc,待办使用 todo,OA 审批使用 oa;缺少稳定日志或模板身份时不要猜测"}, + Examples: examples, + }, + } +} + +func reportResponseError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func reportRequireSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return reportResponseError(operation, "empty_tool_response", "服务返回空响应,无法证明 Report 调用成功或结果确实为空") + } + raw, present := data["success"] + if !present { + return reportResponseError(operation, "missing_success", "Report 响应缺少 success 业务状态") + } + success, ok := raw.(bool) + if !ok { + return reportResponseError(operation, "invalid_success_type", fmt.Sprintf("Report success 应为布尔值,实际为 %T", raw)) + } + if success { + return nil + } + message := "Report 服务明确返回失败" + for _, key := range []string{"errorMsg", "errorMessage", "message"} { + if value, ok := data[key].(string); ok && strings.TrimSpace(value) != "" { + message = strings.TrimSpace(value) + break + } + } + return reportResponseError(operation, "remote_failure", message) +} + +func reportObjectCollection(value any, operation, path string) ([]map[string]any, error) { + raw, ok := value.([]any) + if !ok { + return nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func reportListCollection(data map[string]any, operation string) ([]map[string]any, map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, nil, err + } + result, present := data["result"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result 日志集合") + } + if _, ok := result.([]any); ok { + items, err := reportObjectCollection(result, operation, "result") + return items, data, err + } + container, ok := result.(map[string]any) + if !ok || container == nil { + return nil, nil, reportResponseError(operation, "malformed_collection", fmt.Sprintf("Report result 应为数组或带 report_list 的对象,实际为 %T", result)) + } + raw, present := container["report_list"] + if !present { + return nil, nil, reportResponseError(operation, "missing_collection", "Report 成功响应缺少 result.report_list 数组") + } + items, err := reportObjectCollection(raw, operation, "result.report_list") + return items, container, err +} + +func reportRequiredString(item map[string]any, operation string, index int, keys ...string) (string, error) { + var selected string + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + value = strings.TrimSpace(value) + if !ok || value == "" { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 必须是非空字符串", index, key)) + } + if selected != "" && selected != value { + return "", reportResponseError(operation, "conflicting_item_identity", fmt.Sprintf("Report 结果第 %d 项的身份字段冲突", index)) + } + selected = value + } + if selected == "" { + return "", reportResponseError(operation, "missing_item_identity", fmt.Sprintf("Report 结果第 %d 项缺少稳定身份", index)) + } + return selected, nil +} + +func reportOptionalString(item map[string]any, operation string, index int, keys ...string) (string, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := raw.(string) + if !ok { + return "", reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为字符串,实际为 %T", index, key, raw)) + } + return strings.TrimSpace(value), nil + } + return "", nil +} + +func reportInteger(value any) (int64, bool) { + switch typed := value.(type) { + case float64: + if math.IsNaN(typed) || math.IsInf(typed, 0) || math.Trunc(typed) != typed || typed > math.MaxInt64 || typed < math.MinInt64 { + return 0, false + } + return int64(typed), true + case int: + return int64(typed), true + case int64: + return typed, true + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + default: + return 0, false + } +} + +func reportOptionalInteger(item map[string]any, operation string, index int, keys ...string) (int64, bool, error) { + for _, key := range keys { + raw, present := item[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 结果第 %d 项的 %s 应为整数", index, key)) + } + return value, true, nil + } + return 0, false, nil +} + +func reportProjectEntries(data map[string]any, operation string) ([]map[string]any, reportPageEvidence, error) { + items, pageContainer, err := reportListCollection(data, operation) + if err != nil { + return nil, reportPageEvidence{}, err + } + entries := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "reportId", "report_id", "report_Id") + if identityErr != nil { + return nil, reportPageEvidence{}, identityErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportPageEvidence{}, reportResponseError(operation, "duplicate_item_identity", "当前 Report 页包含重复 reportId") + } + seen[id] = struct{}{} + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "templateName": {"templateName", "template_name", "reportTemplateName", "report_template_name"}, + "creatorName": {"creatorName", "creator_name", "creatorUserName", "senderName", "authorName"}, + "creatorUserId": {"creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"}, + } { + value, valueErr := reportOptionalString(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time", "gmtCreate", "sendTime"}, + "modifiedTime": {"modifiedTime", "modified_time", "gmtModified", "modifyTime"}, + } { + value, present, valueErr := reportOptionalInteger(item, operation, index, aliases...) + if valueErr != nil { + return nil, reportPageEvidence{}, valueErr + } + if present { + projected[target] = value + } + } + entries = append(entries, projected) + } + page, err := reportPage(data, pageContainer, operation, len(entries)) + if err != nil { + return nil, reportPageEvidence{}, err + } + return entries, page, nil +} + +func reportPage(data, result map[string]any, operation string, count int) (reportPageEvidence, error) { + raw, present := data["hasMore"] + if nested, exists := result["hasMore"]; exists { + if present && !sameReportScalar(raw, nested) { + return reportPageEvidence{}, reportResponseError(operation, "conflicting_pagination", "Report 顶层与 result.hasMore 冲突") + } + raw, present = nested, true + } + if !present { + return reportPageEvidence{}, reportResponseError(operation, "missing_pagination", "Report 列表缺少 hasMore,不能把当前页宣称为完整结果") + } + hasMore, ok := raw.(bool) + if !ok { + return reportPageEvidence{}, reportResponseError(operation, "malformed_pagination", "Report hasMore 应为布尔值") + } + page := reportPageEvidence{HasMore: hasMore} + next, nextPresent, err := reportNextCursor(data, result, operation) + if err != nil { + return reportPageEvidence{}, err + } + if !hasMore { + // The Report service echoes an integer cursor on terminal pages. It is a + // page receipt, not a continuation: hasMore=false is authoritative and + // the unified projection intentionally omits next_token. Parsing above + // still rejects wrong types and conflicting cursor fields. + return page, nil + } + if count == 0 { + return reportPageEvidence{}, reportResponseError(operation, "empty_page_with_continuation", "Report 空页仍声明 hasMore=true") + } + if !nextPresent || next <= 0 { + return reportPageEvidence{}, reportResponseError(operation, "missing_next_cursor", "hasMore=true 时必须返回正整数 continuation cursor") + } + page.Next = strconv.FormatInt(next, 10) + return page, nil +} + +func reportNextCursor(data, result map[string]any, operation string) (int64, bool, error) { + var selected int64 + found := false + for _, object := range []map[string]any{data, result} { + for _, key := range []string{"nextCursor", "cursor"} { + raw, present := object[key] + if !present || raw == nil { + continue + } + value, ok := reportInteger(raw) + if !ok { + return 0, false, reportResponseError(operation, "malformed_pagination", fmt.Sprintf("Report %s 应为整数", key)) + } + if found && selected != value { + return 0, false, reportResponseError(operation, "conflicting_pagination", "Report continuation cursor 字段冲突") + } + selected, found = value, true + } + } + return selected, found, nil +} + +func sameReportScalar(left, right any) bool { + leftJSON, leftErr := json.Marshal(left) + rightJSON, rightErr := json.Marshal(right) + return leftErr == nil && rightErr == nil && string(leftJSON) == string(rightJSON) +} + +func reportProjectTemplates(data map[string]any, operation string) ([]map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + raw, present := data["items"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 模板响应缺少 items 数组") + } + items, err := reportObjectCollection(raw, operation, "items") + if err != nil { + return nil, err + } + templates := make([]map[string]any, 0, len(items)) + seen := make(map[string]struct{}, len(items)) + for index, item := range items { + id, identityErr := reportRequiredString(item, operation, index, "report_template_id") + if identityErr != nil { + return nil, identityErr + } + name, nameErr := reportRequiredString(item, operation, index, "report_template_name") + if nameErr != nil { + return nil, nameErr + } + if _, duplicate := seen[id]; duplicate { + return nil, reportResponseError(operation, "duplicate_item_identity", "Report 模板响应包含重复 templateId") + } + seen[id] = struct{}{} + projected := map[string]any{"templateId": id, "name": name} + modified, present, modifiedErr := reportOptionalInteger(item, operation, index, "last_modified_time") + if modifiedErr != nil { + return nil, modifiedErr + } + if present { + projected["lastModifiedTime"] = modified + } + templates = append(templates, projected) + } + return templates, nil +} + +func reportProjectDetail(data map[string]any, operation, expectedID string) (map[string]any, error) { + if err := reportRequireSuccess(data, operation); err != nil { + return nil, err + } + result, ok := data["result"].(map[string]any) + if !ok || len(result) == 0 { + return nil, reportResponseError(operation, "missing_result", "Report 详情成功响应缺少非空 result 对象") + } + id, err := reportRequiredString(result, operation, 0, "report_Id", "reportId", "report_id") + if err != nil { + return nil, err + } + if id != expectedID { + return nil, reportResponseError(operation, "identity_mismatch", "Report 详情 reportId 与请求目标不一致") + } + projected := map[string]any{"reportId": id} + for target, aliases := range map[string][]string{ + "title": {"report_name", "reportName", "title"}, + "templateName": {"report_template_name", "templateName"}, + "creatorName": {"creatorName", "senderName"}, + } { + value, valueErr := reportOptionalString(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if value != "" { + projected[target] = value + } + } + for target, aliases := range map[string][]string{ + "createTime": {"createTime", "create_time"}, "modifiedTime": {"modifiedTime", "modified_time"}, + } { + value, present, valueErr := reportOptionalInteger(result, operation, 0, aliases...) + if valueErr != nil { + return nil, valueErr + } + if present { + projected[target] = value + } + } + rawFields, present := result["report_content"] + if !present { + return nil, reportResponseError(operation, "missing_collection", "Report 详情缺少 result.report_content 数组") + } + fields, err := reportObjectCollection(rawFields, operation, "result.report_content") + if err != nil { + return nil, err + } + projectedFields := make([]map[string]any, 0, len(fields)) + for index, field := range fields { + key, keyErr := reportRequiredString(field, operation, index, "key") + if keyErr != nil { + return nil, keyErr + } + rawValue, valuePresent := field["value"] + value, valueOK := rawValue.(string) + if !valuePresent || !valueOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 value 必须是字符串", index)) + } + sortValue, sortOK := reportInteger(field["sort"]) + typeValue, typeOK := reportInteger(field["type"]) + if !sortOK || !typeOK { + return nil, reportResponseError(operation, "malformed_item", fmt.Sprintf("Report 详情字段第 %d 项的 sort/type 必须是整数", index)) + } + projectedFields = append(projectedFields, map[string]any{"key": key, "value": value, "sort": sortValue, "type": typeValue}) + } + projected["fields"] = projectedFields + return projected, nil +} + +func outputReportPage(rt *shortcut.RuntimeContext, collection string, entries []map[string]any, page reportPageEvidence) error { + payload := map[string]any{"count": len(entries), collection: entries, "complete": !page.HasMore} + if !output.UsesUnifiedResult(rt.Command()) { + if page.HasMore { + payload["nextCursor"] = page.Next + } + return rt.Output(payload) + } + pagination, err := output.NewPagination(!page.HasMore, page.Next) + if err != nil { + return reportResponseError("report/pagination", "invalid_pagination", err.Error()) + } + meta := &output.Meta{Count: output.NewCount(len(entries)), Pagination: pagination} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) +} diff --git a/internal/shortcut/report/latest.go b/internal/shortcut/report/latest.go new file mode 100644 index 00000000..1d633c5b --- /dev/null +++ b/internal/shortcut/report/latest.go @@ -0,0 +1,130 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +var ReportLatest = shortcut.Shortcut{ + Service: "report", Command: "+report-latest", Product: "report", + Description: "读取我最近提交的一篇日志详情", + Intent: "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+report-latest", "读取我最近提交的一篇日志详情", + "只想查看明确 20 天内自己提交的最新日志详情时使用;默认最近 20 天,也可成对指定创建时间窗,完整验证候选后按精确 reportId 读回详情。", + reportLatestResult(), nil, + []contract.ParamDecl{ + {Name: "keyword", Property: "report_template_name"}, + {Name: "start", Property: "startTime"}, {Name: "end", Property: "endTime"}, + }, + "dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`, + ), + Flags: []shortcut.Flag{ + {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名称精确过滤"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "--start 与 --end 必须同时提供,且创建时间范围必须有效并不得超过 20 天"}, + }, + Tips: []string{"dws report +report-latest", `dws report +report-latest --start "2026-03-01T00:00:00+08:00" --end "2026-03-20T00:00:00+08:00"`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if rt.Changed("start") != rt.Changed("end") { + return apperrors.NewValidation("--start 与 --end 必须同时提供", apperrors.WithReason("incomplete_creation_range")) + } + if rt.Changed("start") { + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + return err + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const listOperation = "report/get_send_report_list" + now := time.Now() + end := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + start := end.Add(-reportMaximumOutboxDays * 24 * time.Hour) + if rt.Changed("start") { + startMillis, endMillis, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays) + if err != nil { + return err + } + start, end = time.UnixMilli(startMillis), time.UnixMilli(endMillis) + } + params := map[string]any{ + "cursor": 0, "size": 20, + "startTime": start.UnixMilli(), "endTime": end.UnixMilli(), + } + if keyword := strings.TrimSpace(rt.Str("keyword")); keyword != "" { + params["report_template_name"] = keyword + } + data, err := rt.CallMCPData("report", "get_send_report_list", params) + if err != nil { + return err + } + entries, page, err := reportProjectEntries(data, listOperation) + if err != nil { + return err + } + if page.HasMore { + return reportResponseError(listOperation, "incomplete_latest_candidates", "发件箱仍有后续页,不能从不完整集合宣称最新日志") + } + if len(entries) == 0 { + return apperrors.NewValidation("最近 20 天没有可验证的已发送日志", apperrors.WithReason("no_sent_report_fixture")) + } + latestID, err := reportLatestEntryID(entries, listOperation) + if err != nil { + return err + } + const detailOperation = "report/get_report_entry_details" + detailData, err := rt.CallMCPData("report", "get_report_entry_details", map[string]any{"report_id": latestID}) + if err != nil { + return err + } + detail, err := reportProjectDetail(detailData, detailOperation, latestID) + if err != nil { + return err + } + return rt.Output(map[string]any{"report": detail}) + }, +} + +func reportLatestEntryID(entries []map[string]any, operation string) (string, error) { + var latestID string + var latestTime int64 + latestCount := 0 + for index, entry := range entries { + created, ok := entry["createTime"].(int64) + if !ok || created <= 0 { + return "", reportResponseError(operation, "missing_latest_order", "发件箱项目缺少正整数 createTime,不能证明哪一篇最新") + } + id, ok := entry["reportId"].(string) + if !ok || strings.TrimSpace(id) == "" { + return "", reportResponseError(operation, "missing_item_identity", "发件箱项目缺少稳定 reportId") + } + if index == 0 || created > latestTime { + latestID, latestTime = id, created + latestCount = 1 + } else if created == latestTime { + latestCount++ + } + } + if latestCount != 1 { + return "", reportResponseError(operation, "ambiguous_latest_order", "多篇日志具有相同的最高 createTime,不能确定唯一最新日志") + } + return latestID, nil +} + +func init() { + shortcut.Register(ReportLatest) +} diff --git a/internal/shortcut/report/report.go b/internal/shortcut/report/report.go index 28aa7938..9fb8c52b 100644 --- a/internal/shortcut/report/report.go +++ b/internal/shortcut/report/report.go @@ -1,103 +1,126 @@ // Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. +// Licensed under the Apache License, Version 2.0 -// Package report declares declarative shortcuts for the DingTalk -// "报告/日志" (OA 周报应用) MCP tools. Each shortcut is a thin wrapper over a -// single MCP tool call; tool names and parameter keys mirror the DWS report -// helper (internal/helpers/report.go) verbatim. +// Package report registers strict declarative shortcuts for DingTalk reports. package report import ( "fmt" + "strconv" "strings" "time" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) -// reportParseISOMillis parses an ISO-8601 timestamp (e.g. 2026-03-10T00:00:00+08:00) -// into Unix milliseconds, matching the helper's parseISOTimeToMillis behavior. -func reportParseISOMillis(name, s string) (int64, error) { - s = strings.TrimSpace(s) - t, err := time.Parse(time.RFC3339, s) +const ( + reportMaximumInboxDays = 180 + reportMaximumOutboxDays = 20 +) + +func reportParseISOMillis(name, value string) (int64, error) { + parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value)) if err != nil { - return 0, fmt.Errorf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00): %v", name, err) + return 0, apperrors.NewValidation( + fmt.Sprintf("--%s 时间格式无效,需 ISO-8601(如 2026-03-10T00:00:00+08:00)", name), + apperrors.WithReason("invalid_time_format"), + ) } - return t.UnixMilli(), nil + return parsed.UnixMilli(), nil +} + +func reportValidatePage(cursor, size int) error { + if cursor < 0 { + return apperrors.NewValidation("--cursor 不能小于 0", apperrors.WithReason("invalid_cursor")) + } + if size < 1 || size > 20 { + return apperrors.NewValidation("--size 必须在 1 到 20 之间", apperrors.WithReason("invalid_page_size")) + } + return nil +} + +func reportValidateRange(startName, startValue, endName, endValue string, maximumDays int) (int64, int64, error) { + start, err := reportParseISOMillis(startName, startValue) + if err != nil { + return 0, 0, err + } + end, err := reportParseISOMillis(endName, endValue) + if err != nil { + return 0, 0, err + } + if end <= start { + return 0, 0, apperrors.NewValidation("结束时间必须晚于开始时间", apperrors.WithReason("invalid_time_range")) + } + if maximumDays > 0 && end-start > int64(maximumDays)*int64(24*time.Hour/time.Millisecond) { + return 0, 0, apperrors.NewValidation( + fmt.Sprintf("时间范围不能超过 %d 天", maximumDays), + apperrors.WithReason("time_range_too_large"), + ) + } + return start, end, nil +} + +func reportValidateContinuation(page reportPageEvidence, current int, operation string) error { + if !page.HasMore { + return nil + } + next, err := strconv.ParseInt(page.Next, 10, 64) + if err != nil || next <= int64(current) { + return reportResponseError(operation, "stalled_cursor", "Report continuation cursor 没有严格前进") + } + return nil } -// TemplateList lists the report templates available to the current user. -// TemplateGet reads a single report template's field definitions by name. -// InboxList lists reports received by the current user within a time window. var InboxList = shortcut.Shortcut{ - Service: "report", - Command: "+inbox-list", - Product: "report", - Description: "列出我收到的日报(按时间范围分页)", - Intent: "当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_inbox_list", - CanonicalPath: "report.shortcut_inbox_list", - CLIPath: "report +inbox-list", - PrimaryCLIPath: "report +inbox-list", + Service: "report", Command: "+inbox-list", Product: "report", + Description: "列出我收到的日志", + Intent: "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+inbox-list", "列出我收到的日志", + "需要按明确时间范围读取别人发给我的日志摘要并取得稳定 reportId 时使用;后端必须提供可验证的终止或严格前进 cursor。", + reportCollectionResult("reports", "严格验证的收件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "sender-user-ids", Property: "senderUserIds", InterfaceType: "array"}, }, - Description: "列出我收到的日报(按时间范围分页)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我收到的日报(按时间范围分页)", - UseWhen: []string{"当你要查看下属或同事发给自己的日报周报、想在某个时间段内浏览或审阅收到的汇报时使用;输入起止时间(ISO-8601),可按发送人 staffId 过滤,分页返回收到的日报列表及其 reportId,供后续 +entry-get 读正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{"dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20"}, - }, - }, + "dws report +inbox-list --start \"2026-03-10T00:00:00+08:00\" --end \"2026-03-10T23:59:59+08:00\" --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)", Required: true}, - {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)", Required: true}, - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表,逗号分隔,过滤指定发送人"}, + {Name: "start", Type: shortcut.FlagString, Desc: "开始时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "end", Type: shortcut.FlagString, Desc: "结束时间 ISO-8601;结束时间必须晚于开始时间,跨度不得超过 180 天", Required: true}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "sender-user-ids", Type: shortcut.FlagStringSlice, Desc: "发送人 staffId 列表"}, }, - Tips: []string{ - `dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "结束时间必须晚于开始时间,跨度不得超过 180 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - startMs, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + Tips: []string{`dws report +inbox-list --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --cursor 0 --size 20`}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { return err } - endMs, err := reportParseISOMillis("end", rt.Str("end")) + _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) + return err + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_received_report_list" + start, end, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumInboxDays) if err != nil { return err } params := map[string]any{ - "startTime": startMs, - "endTime": endMs, - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + "startTime": start, "endTime": end, + "cursor": rt.Int("cursor"), "size": rt.Int("size"), } if rt.Changed("sender-user-ids") { params["senderUserIds"] = rt.StrSlice("sender-user-ids") @@ -106,192 +129,164 @@ var InboxList = shortcut.Shortcut{ if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// reportEntryListProject reshapes the raw report-list responses -// (get_received_report_list / get_send_report_list) into a clean, stable list -// of report summaries — the output-projection fidelity the framework applies to -// every list command. Both the list container and the per-item field names are -// probed defensively across candidate keys so the projection tolerates -// response-shape drift and never fabricates data: an empty/unknown shape yields -// an empty list rather than a crash. -func reportEntryListProject(data map[string]any) []map[string]any { - raw := reportEntryListResolveList(data) - out := make([]map[string]any, 0, len(raw)) - for _, item := range raw { - m, ok := item.(map[string]any) - if !ok { - continue - } - row := map[string]any{} - if v, ok := reportEntryListFirst(m, "reportId", "report_id", "id"); ok { - row["reportId"] = v - } - if v, ok := reportEntryListFirst(m, "templateName", "template_name", "reportTemplateName"); ok { - row["templateName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorName", "creator_name", "creatorUserName", "senderName", "authorName"); ok { - row["creatorName"] = v - } - if v, ok := reportEntryListFirst(m, "creatorUserId", "creator_user_id", "creatorId", "senderUserId", "userId"); ok { - row["creatorUserId"] = v - } - if v, ok := reportEntryListFirst(m, "createTime", "create_time", "gmtCreate", "sendTime"); ok { - row["createTime"] = v - } - if v, ok := reportEntryListFirst(m, "modifiedTime", "modified_time", "gmtModified", "modifyTime"); ok { - row["modifiedTime"] = v - } - if len(row) > 0 { - out = append(out, row) - } - } - return out -} - -// reportEntryListResolveList locates the list payload inside the response, -// tolerating a bare top-level array or nesting one level under a common -// envelope key. -func reportEntryListResolveList(data map[string]any) []any { - if data == nil { - return []any{} - } - // get_received_report_list / get_send_report_list nest the list under - // result.report_list (snake_case); "report_list" MUST be probed — the - // camelCase "reportList" alone leaves +inbox-list / +outbox-list silently - // empty despite the backend returning reports. - for _, key := range []string{"result", "data", "list", "items", "report_list", "reportList", "records"} { - v, ok := data[key] - if !ok { - continue - } - if arr, ok := v.([]any); ok { - return arr - } - if inner, ok := v.(map[string]any); ok { - for _, ik := range []string{"list", "items", "report_list", "reportList", "records", "result", "data"} { - if arr, ok := inner[ik].([]any); ok { - return arr - } - } - } - } - return []any{} -} - -// reportEntryListFirst returns the first present candidate key's value. -func reportEntryListFirst(m map[string]any, keys ...string) (any, bool) { - for _, k := range keys { - if v, ok := m[k]; ok { - return v, true - } - } - return nil, false -} - -// OutboxList lists reports sent/created by the current user. var OutboxList = shortcut.Shortcut{ - Service: "report", - Command: "+outbox-list", - Product: "report", - Description: "列出我发出的日报(可选时间/模版名过滤)", - Intent: "当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "report", - Name: "shortcut_outbox_list", - CanonicalPath: "report.shortcut_outbox_list", - CLIPath: "report +outbox-list", - PrimaryCLIPath: "report +outbox-list", + Service: "report", Command: "+outbox-list", Product: "report", + Description: "列出我发出的日志", + Intent: "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+outbox-list", "列出我发出的日志", + "需要按创建或修改时间回顾自己提交的日志并取得稳定 reportId 时使用;每次创建或修改时间窗不得超过 20 天。", + reportCollectionResult("reports", "严格验证的发件箱日志页"), reportPagination(), + []contract.ParamDecl{ + {Name: "cursor", Property: "cursor"}, {Name: "size", Property: "size"}, + {Name: "start", Property: "start"}, {Name: "end", Property: "end"}, + {Name: "modified-start", Property: "modifiedStart"}, {Name: "modified-end", Property: "modifiedEnd"}, + {Name: "template-name", Property: "templateName"}, }, - Description: "列出我发出的日报(可选时间/模版名过滤)", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发出的日报(可选时间/模版名过滤)", - UseWhen: []string{"当你要回顾自己写过、提交过的日报周报,比如确认某天是否已交、找回历史汇报内容或统计提交情况时使用;可按创建/修改时间范围和模版名过滤,分页返回自己发出的日报列表及 reportId,供后续 +entry-get 查看正文。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws report +outbox-list --cursor 0 --size 20", - "dws report +outbox-list --cursor 0 --size 20 --template-name \"日报\"", - }, - }, - }, + "dws report +outbox-list --cursor 0 --size 20", + ), Flags: []shortcut.Flag{ - {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标,首次传 0"}, - {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数,最大 20"}, - {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601 (可选,服务端单次跨度上限 20 天)"}, - {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601 (可选)"}, - {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601 (可选)"}, - {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601 (可选)"}, - {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称 (可选,不传查全部)"}, + {Name: "cursor", Type: shortcut.FlagInt, Default: "0", Desc: "分页游标;--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Name: "size", Type: shortcut.FlagInt, Default: "20", Desc: "每页条数;--size 必须在 1 到 20 之间"}, + {Name: "start", Type: shortcut.FlagString, Desc: "创建开始时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "end", Type: shortcut.FlagString, Desc: "创建结束时间 ISO-8601;创建时间范围必须有效且不得超过 20 天"}, + {Name: "modified-start", Type: shortcut.FlagString, Desc: "修改开始时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "modified-end", Type: shortcut.FlagString, Desc: "修改结束时间 ISO-8601;修改时间必须成对提供、范围有效且不得超过 20 天"}, + {Name: "template-name", Type: shortcut.FlagString, Desc: "日志模板名称"}, }, - Tips: []string{ - `dws report +outbox-list --cursor 0 --size 20`, - `dws report +outbox-list --cursor 0 --size 20 --template-name "日报"`, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"cursor"}, Description: "--cursor 不能小于 0,续页 cursor 必须严格前进"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "--size 必须在 1 到 20 之间"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"start", "end"}, Description: "创建时间范围必须有效且不得超过 20 天"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"modified-start", "modified-end"}, Description: "修改时间必须成对提供、范围有效且不得超过 20 天"}, }, - Execute: func(rt *shortcut.RuntimeContext) error { - params := map[string]any{ - "cursor": rt.Int("cursor"), - "size": rt.Int("size"), + Tips: []string{"dws report +outbox-list --cursor 0 --size 20"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := reportValidatePage(rt.Int("cursor"), rt.Int("size")); err != nil { + return err } - if rt.Changed("start") { - ms, err := reportParseISOMillis("start", rt.Str("start")) - if err != nil { + if rt.Changed("start") && rt.Changed("end") { + if _, _, err := reportValidateRange("start", rt.Str("start"), "end", rt.Str("end"), reportMaximumOutboxDays); err != nil { return err } - params["startTime"] = ms } - if rt.Changed("end") { - ms, err := reportParseISOMillis("end", rt.Str("end")) - if err != nil { - return err - } - params["endTime"] = ms + if rt.Changed("modified-start") != rt.Changed("modified-end") { + return apperrors.NewValidation("--modified-start 与 --modified-end 必须同时提供", apperrors.WithReason("incomplete_modified_range")) } if rt.Changed("modified-start") { - ms, err := reportParseISOMillis("modified-start", rt.Str("modified-start")) - if err != nil { - return err - } - params["modifiedStartTime"] = ms + _, _, err := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + return err } - if rt.Changed("modified-end") { - ms, err := reportParseISOMillis("modified-end", rt.Str("modified-end")) - if err != nil { - return err - } - params["modifiedEndTime"] = ms + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_send_report_list" + now := time.Now() + defaultEnd := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()) + startValue := defaultEnd.Add(-reportMaximumOutboxDays * 24 * time.Hour).Format(time.RFC3339) + endValue := defaultEnd.Format(time.RFC3339) + if rt.Changed("start") { + startValue = rt.Str("start") } - if rt.Changed("template-name") { - params["report_template_name"] = rt.Str("template-name") + if rt.Changed("end") { + endValue = rt.Str("end") + } + start, end, err := reportValidateRange("start", startValue, "end", endValue, reportMaximumOutboxDays) + if err != nil { + return err + } + params := map[string]any{ + "cursor": rt.Int("cursor"), "size": rt.Int("size"), + "startTime": start, "endTime": end, + } + if rt.Changed("modified-start") { + modifiedStart, modifiedEnd, rangeErr := reportValidateRange("modified-start", rt.Str("modified-start"), "modified-end", rt.Str("modified-end"), reportMaximumOutboxDays) + if rangeErr != nil { + return rangeErr + } + params["modifiedStartTime"], params["modifiedEndTime"] = modifiedStart, modifiedEnd + } + if name := strings.TrimSpace(rt.Str("template-name")); name != "" { + params["report_template_name"] = name } data, err := rt.CallMCPData("report", "get_send_report_list", params) if err != nil { return err } - reports := reportEntryListProject(data) - return rt.Output(map[string]any{"count": len(reports), "reports": reports}) + reports, page, err := reportProjectEntries(data, operation) + if err != nil { + return err + } + if err := reportValidateContinuation(page, rt.Int("cursor"), operation); err != nil { + return err + } + return outputReportPage(rt, "reports", reports, page) }, } -// EntryGet reads the full body of a single report by id. -// EntryStats reads the read-receipt statistics of a single report. -// EntrySubmit submits a new report against a template. +var TemplateSearch = shortcut.Shortcut{ + Service: "report", Command: "+template-search", Product: "report", + Description: "按名称搜索可用日志模板", + Intent: "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + Risk: shortcut.RiskRead, + Safety: reportReadSafety(), + OutputRollout: output.RolloutUnifiedActive, + Contract: reportContract( + "+template-search", "按名称搜索可用日志模板", + "需要从当前用户全部可用日志模板中按名称查找稳定 templateId,或在提交日志前确认模板是否存在时使用。", + reportTemplateSearchResult(), nil, + []contract.ParamDecl{{Name: "query", Property: "report_template_name"}}, + "dws report +template-search --query 周报", + ), + Flags: []shortcut.Flag{{Name: "query", Type: shortcut.FlagString, Desc: "模板名称关键词,不区分大小写;--query 不能为空", Required: true}}, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "--query 不能为空"}}, + Tips: []string{"dws report +template-search --query 周报"}, + Validate: func(rt *shortcut.RuntimeContext) error { + if strings.TrimSpace(rt.Str("query")) == "" { + return apperrors.NewValidation("--query 不能为空", apperrors.WithReason("empty_query")) + } + return nil + }, + Execute: func(rt *shortcut.RuntimeContext) error { + const operation = "report/get_available_report_templates" + data, err := rt.CallMCPData("report", "get_available_report_templates", map[string]any{}) + if err != nil { + return err + } + templates, err := reportProjectTemplates(data, operation) + if err != nil { + return err + } + query := strings.ToLower(strings.TrimSpace(rt.Str("query"))) + matches := make([]map[string]any, 0) + for _, template := range templates { + if strings.Contains(strings.ToLower(template["name"].(string)), query) { + matches = append(matches, template) + } + } + payload := map[string]any{"count": len(matches), "templates": matches} + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + meta := &output.Meta{Count: output.NewCount(len(matches))} + return output.StoreResult(rt.Command().Context(), output.Success(payload, output.WithMeta(meta))) + }, +} + func init() { - shortcut.Register( - InboxList, - OutboxList, - ) + shortcut.Register(InboxList, OutboxList, TemplateSearch) } diff --git a/internal/shortcut/report/report_list_projection_test.go b/internal/shortcut/report/report_list_projection_test.go deleted file mode 100644 index faa8c95e..00000000 --- a/internal/shortcut/report/report_list_projection_test.go +++ /dev/null @@ -1,38 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package report - -import ( - "encoding/json" - "testing" -) - -// TestReportEntryListResolveListSnakeShape guards against projection-data-loss: -// get_received_report_list / get_send_report_list nest the list under -// result.report_list (snake_case); the resolver must probe "report_list" or -// +inbox-list / +outbox-list silently return empty despite the backend having -// reports. -func TestReportEntryListResolveListSnakeShape(t *testing.T) { - const raw = `{"result":{"report_list":[ - {"reportId":"r1","templateName":"daily report"}, - {"reportId":"r2","templateName":"weekly report"} - ]}}` - var data map[string]any - if err := json.Unmarshal([]byte(raw), &data); err != nil { - t.Fatalf("unmarshal fixture: %v", err) - } - if got := reportEntryListResolveList(data); len(got) != 2 { - t.Fatalf("lower/upper mismatch: result.report_list has 2 entries, resolver returned %d", len(got)) - } -} diff --git a/internal/shortcut/report/report_strict_cross_platform_coverage_test.go b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..48fae36f --- /dev/null +++ b/internal/shortcut/report/report_strict_cross_platform_coverage_test.go @@ -0,0 +1,625 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package report + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "math" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "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 reportCoverageCall struct { + tool string + args map[string]any +} + +type reportCoverageCaller struct { + responses map[string][]string + history []reportCoverageCall +} + +func (caller *reportCoverageCaller) CallTool(_ context.Context, _, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.history = append(caller.history, reportCoverageCall{tool: tool, args: args}) + queue := caller.responses[tool] + if len(queue) == 0 { + return nil, errors.New("missing Report fake response for " + tool) + } + caller.responses[tool] = queue[1:] + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: queue[0]}}}, nil +} + +func (*reportCoverageCaller) Format() string { return "json" } +func (*reportCoverageCaller) DryRun() bool { return false } +func (*reportCoverageCaller) Fields() string { return "" } +func (*reportCoverageCaller) JQ() string { return "" } + +func runReportCoverage(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) (*cobra.Command, error) { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd, cmd.Execute() +} + +func directReportRuntime(t *testing.T, declaration shortcut.Shortcut, caller *reportCoverageCaller, args ...string) *shortcut.RuntimeContext { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + if err := cmd.Flags().Parse(args); err != nil { + t.Fatal(err) + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageReportContractsAreStrictTypedAndUnified(t *testing.T) { + declarations := []shortcut.Shortcut{InboxList, OutboxList, TemplateSearch, ReportLatest} + for _, declaration := range declarations { + if declaration.Contract.Empty() || declaration.Contract.Result == nil { + t.Errorf("%s lacks Contract/Result", declaration.Command) + } + if declaration.Safety.Effect != "read" || declaration.Safety.Confirmation != "not_required" { + t.Errorf("%s safety=%+v", declaration.Command, declaration.Safety) + } + if declaration.OutputRollout != output.RolloutUnifiedActive { + t.Errorf("%s rollout=%q", declaration.Command, declaration.OutputRollout) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != "available" { + t.Errorf("%s interface=%+v", declaration.Command, declaration.Contract.Interface) + } + } + if InboxList.Contract.Pagination == nil || OutboxList.Contract.Pagination == nil { + t.Fatal("Report list shortcuts must publish cursor pagination") + } + if TemplateSearch.Contract.Pagination != nil || ReportLatest.Contract.Pagination != nil { + t.Fatal("non-paginated Report shortcuts published pagination") + } +} + +func TestCrossPlatformCoverageReportListResponseMatrix(t *testing.T) { + valid := map[string]any{ + "success": true, + "result": map[string]any{ + "report_list": []any{map[string]any{"reportId": "report-1", "templateName": "fixture", "createTime": float64(10)}}, + }, + "hasMore": false, + } + entries, page, err := reportProjectEntries(valid, "report/get_received_report_list") + if err != nil || len(entries) != 1 || page.HasMore || entries[0]["createTime"] != int64(10) { + t.Fatalf("valid projection entries=%#v page=%+v err=%v", entries, page, err) + } + empty := map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "cursor": nil} + if entries, page, err := reportProjectEntries(empty, "report/get_received_report_list"); err != nil || len(entries) != 0 || page.HasMore { + t.Fatalf("terminal empty entries=%#v page=%+v err=%v", entries, page, err) + } + terminalReceipt := map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": false, "nextCursor": float64(1)} + if entries, page, err := reportProjectEntries(terminalReceipt, "report/get_received_report_list"); err != nil || len(entries) != 1 || page.HasMore || page.Next != "" { + t.Fatalf("terminal cursor receipt entries=%#v page=%+v err=%v", entries, page, err) + } + fixtures := map[string]map[string]any{ + "empty response": map[string]any{}, + "missing success": map[string]any{"result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "wrong success": map[string]any{"success": "true", "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "remote failure": map[string]any{"success": false, "result": map[string]any{"report_list": []any{}}, "hasMore": false}, + "missing result": map[string]any{"success": true, "hasMore": false}, + "wrong result": map[string]any{"success": true, "result": "bad", "hasMore": false}, + "missing collection": map[string]any{"success": true, "result": map[string]any{}, "hasMore": false}, + "wrong collection": map[string]any{"success": true, "result": map[string]any{"report_list": map[string]any{}}, "hasMore": false}, + "bad item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{"bad"}}, "hasMore": false}, + "empty item": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{}}}, "hasMore": false}, + "missing identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"createTime": float64(1)}}}, "hasMore": false}, + "wrong identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": float64(1)}}}, "hasMore": false}, + "duplicate identity": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "same"}, map[string]any{"reportId": "same"}}}, "hasMore": false}, + "wrong optional": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "createTime": "1"}}}, "hasMore": false}, + "missing pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}}, + "wrong pagination": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": "false"}, + "empty continuation": map[string]any{"success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": true, "nextCursor": float64(2)}, + "missing continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, + }, + "wrong continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}}, "hasMore": true, "nextCursor": "2", + }, + "wrong terminal cursor": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}}, "hasMore": false, "nextCursor": "2", + }, + "conflicting has more": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{}, "hasMore": true}, "hasMore": false, + }, + "conflicting continuation": map[string]any{ + "success": true, "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1"}}, "cursor": float64(3)}, "hasMore": true, "nextCursor": float64(2), + }, + } + for name, fixture := range fixtures { + if projected, _, projectErr := reportProjectEntries(fixture, "report/get_received_report_list"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "2"}, 2, "report/list"); err == nil { + t.Fatal("stalled continuation returned success") + } +} + +func TestCrossPlatformCoverageReportTemplateResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "items": []any{ + map[string]any{"report_template_id": "template-1", "report_template_name": "Fixture Weekly", "last_modified_time": float64(2)}, + map[string]any{"report_template_id": "template-2", "report_template_name": "Fixture Daily"}, + }} + templates, err := reportProjectTemplates(valid, "report/get_available_report_templates") + if err != nil || len(templates) != 2 || templates[0]["lastModifiedTime"] != int64(2) { + t.Fatalf("valid templates=%#v err=%v", templates, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing success": {"items": []any{}}, + "wrong success": {"success": float64(1), "items": []any{}}, + "missing collection": {"success": true}, + "wrong collection": {"success": true, "items": map[string]any{}}, + "bad item": {"success": true, "items": []any{"bad"}}, + "missing id": {"success": true, "items": []any{map[string]any{"report_template_name": "fixture"}}}, + "missing name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1"}}}, + "wrong name": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": float64(1)}}}, + "duplicate id": {"success": true, "items": []any{map[string]any{"report_template_id": "same", "report_template_name": "one"}, map[string]any{"report_template_id": "same", "report_template_name": "two"}}}, + "wrong modified": {"success": true, "items": []any{map[string]any{"report_template_id": "template-1", "report_template_name": "fixture", "last_modified_time": "2"}}}, + } { + if projected, projectErr := reportProjectTemplates(fixture, "report/get_available_report_templates"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } + if projected, err := reportProjectTemplates(map[string]any{"success": true, "items": []any{}}, "report/templates"); err != nil || len(projected) != 0 { + t.Fatalf("explicit empty template collection=%#v err=%v", projected, err) + } +} + +func TestCrossPlatformCoverageReportDetailResponseMatrix(t *testing.T) { + valid := map[string]any{"success": true, "result": map[string]any{ + "report_Id": "report-2", "report_name": "fixture", "createTime": float64(2), + "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": float64(1), "type": float64(2)}}, + }} + detail, err := reportProjectDetail(valid, "report/get_report_entry_details", "report-2") + if err != nil || detail["reportId"] != "report-2" || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("valid detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "empty response": {}, + "missing result": {"success": true}, + "empty result": {"success": true, "result": map[string]any{}}, + "missing id": {"success": true, "result": map[string]any{"report_content": []any{}}}, + "identity mismatch": {"success": true, "result": map[string]any{"report_Id": "other", "report_content": []any{}}}, + "missing collection": {"success": true, "result": map[string]any{"report_Id": "report-2"}}, + "wrong collection": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": map[string]any{}}}, + "bad field": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{"bad"}}}, + "missing field key": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"value": "value", "sort": float64(1), "type": float64(2)}}}}, + "wrong field type": {"success": true, "result": map[string]any{"report_Id": "report-2", "report_content": []any{map[string]any{"key": "field", "value": "value", "sort": "1", "type": float64(2)}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, "report/get_report_entry_details", "report-2"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportExactShortcutsProjectUnifiedData(t *testing.T) { + templateCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_available_report_templates": {`{"success":true,"items":[{"report_template_id":"template-1","report_template_name":"Fixture Weekly","last_modified_time":2},{"report_template_id":"template-2","report_template_name":"Fixture Daily"}]}`}, + }} + cmd, err := runReportCoverage(t, TemplateSearch, templateCaller, "--query", "weekly") + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if code, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted || code != 0 { + t.Fatalf("emit=(%d,%t,%v)", code, emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data := envelope["data"].(map[string]any) + if data["count"] != float64(1) || len(data["templates"].([]any)) != 1 { + t.Fatalf("template search data=%#v", data) + } + if _, leaked := data["items"]; leaked { + t.Fatalf("template search leaked raw collection: %#v", data) + } + + inboxCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false,"cursor":null}`}, + }} + cmd, err = runReportCoverage(t, InboxList, inboxCaller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + stdout.Reset() + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("inbox emit=(%t,%v)", emitted, emitErr) + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + data = envelope["data"].(map[string]any) + if data["count"] != float64(1) || data["complete"] != true { + t.Fatalf("inbox data=%#v", data) + } +} + +func TestCrossPlatformCoverageReportLatestRequiresCompleteOrderedListAndExactReadback(t *testing.T) { + missingOrder := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, missingOrder); err == nil || len(missingOrder.history) != 1 { + t.Fatalf("missing order err=%v history=%v", err, missingOrder.history) + } + + incomplete := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":true,"nextCursor":20}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, incomplete); err == nil || len(incomplete.history) != 1 { + t.Fatalf("incomplete err=%v history=%v", err, incomplete.history) + } + + tied := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":2},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + }} + if _, err := runReportCoverage(t, ReportLatest, tied); err == nil || len(tied.history) != 1 { + t.Fatalf("tied latest err=%v history=%v", err, tied.history) + } + + caller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1},{"reportId":"report-2","createTime":2}]},"hasMore":false}`}, + "get_report_entry_details": {`{"success":true,"result":{"report_Id":"report-2","report_name":"fixture","createTime":2,"report_content":[{"key":"field","value":"value","sort":1,"type":2}]}}`}, + }} + cmd, err := runReportCoverage(t, ReportLatest, caller, + "--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-20T00:00:00+08:00") + if err != nil { + t.Fatal(err) + } + if len(caller.history) != 2 || caller.history[0].tool != "get_send_report_list" || caller.history[1].tool != "get_report_entry_details" || caller.history[1].args["report_id"] != "report-2" { + t.Fatalf("exact call history=%#v", caller.history) + } + if caller.history[0].args["startTime"] != int64(1782835200000) || caller.history[0].args["endTime"] != int64(1784476800000) { + t.Fatalf("explicit range args=%#v", caller.history[0].args) + } + var stdout bytes.Buffer + cmd.SetOut(&stdout) + if _, emitted, emitErr := output.EmitStoredResult(cmd); emitErr != nil || !emitted { + t.Fatalf("latest emit=(%t,%v)", emitted, emitErr) + } + var envelope map[string]any + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + projected := envelope["data"].(map[string]any)["report"].(map[string]any) + _, leaked := projected["success"] + if projected["reportId"] != "report-2" || leaked { + t.Fatalf("latest projection=%s", stdout.String()) + } +} + +func TestCrossPlatformCoverageReportValidationRejectsInvalidRangesBeforeMCP(t *testing.T) { + tests := []struct { + declaration shortcut.Shortcut + args []string + }{ + {InboxList, []string{"--start", "2026-07-02T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + {InboxList, []string{"--start", "2026-07-01T00:00:00+08:00", "--end", "2026-07-02T00:00:00+08:00", "--size", "21"}}, + {OutboxList, []string{"--modified-start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-01T00:00:00+08:00"}}, + {ReportLatest, []string{"--start", "2026-07-22T00:00:00+08:00", "--end", "2026-07-01T00:00:00+08:00"}}, + } + for _, test := range tests { + caller := &reportCoverageCaller{responses: map[string][]string{}} + if _, err := runReportCoverage(t, test.declaration, caller, test.args...); err == nil || len(caller.history) != 0 { + t.Errorf("%s args=%v err=%v calls=%v", test.declaration.Command, test.args, err, caller.history) + } + } +} + +func TestCrossPlatformCoverageReportPrimitiveStrictnessAndProjectionBranches(t *testing.T) { + operation := "report/coverage" + if err := reportRequireSuccess(map[string]any{"success": false, "errorMessage": " rejected "}, operation); err == nil { + t.Fatal("remote failure with message returned success") + } + arrayEntries, container, err := reportListCollection(map[string]any{ + "success": true, + "result": []any{map[string]any{"report_id": "report-array"}}, + "hasMore": false, + }, operation) + if err != nil || len(arrayEntries) != 1 || container["hasMore"] != false { + t.Fatalf("array collection entries=%#v container=%#v err=%v", arrayEntries, container, err) + } + if _, err := reportRequiredString(map[string]any{"reportId": "one", "report_id": "two"}, operation, 0, "reportId", "report_id"); err == nil { + t.Fatal("conflicting aliases returned success") + } + if _, err := reportOptionalString(map[string]any{"name": 1}, operation, 0, "name"); err == nil { + t.Fatal("non-string optional value returned success") + } + + integerCases := []struct { + value any + want int64 + ok bool + }{ + {float64(7), 7, true}, {math.NaN(), 0, false}, {math.Inf(1), 0, false}, + {1.5, 0, false}, {int(8), 8, true}, {int64(9), 9, true}, + {json.Number("10"), 10, true}, {json.Number("bad"), 0, false}, {"11", 0, false}, + } + for _, test := range integerCases { + got, ok := reportInteger(test.value) + if got != test.want || ok != test.ok { + t.Errorf("reportInteger(%#v)=(%d,%t), want (%d,%t)", test.value, got, ok, test.want, test.ok) + } + } + + fullEntry := map[string]any{ + "success": true, + "result": map[string]any{ + "hasMore": true, + "nextCursor": json.Number("2"), + "report_list": []any{map[string]any{ + "report_id": "report-full", "report_template_name": "Weekly", + "senderName": "sender", "senderUserId": "user", "gmtCreate": int(12), "modifyTime": int64(13), + }}, + }, + } + entries, page, err := reportProjectEntries(fullEntry, operation) + if err != nil || len(entries) != 1 || !page.HasMore || page.Next != "2" || entries[0]["modifiedTime"] != int64(13) { + t.Fatalf("full entry entries=%#v page=%+v err=%v", entries, page, err) + } + if err := reportValidateContinuation(page, 1, operation); err != nil { + t.Fatalf("advancing continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{}, 0, operation); err != nil { + t.Fatalf("terminal continuation: %v", err) + } + if err := reportValidateContinuation(reportPageEvidence{HasMore: true, Next: "bad"}, 0, operation); err == nil { + t.Fatal("malformed continuation returned success") + } + badOptional := map[string]any{ + "success": true, "hasMore": false, + "result": map[string]any{"report_list": []any{map[string]any{"reportId": "report-1", "senderName": 1}}}, + } + if _, _, err := reportProjectEntries(badOptional, operation); err == nil { + t.Fatal("bad optional entry field returned success") + } + + completeDetail := map[string]any{"success": true, "result": map[string]any{ + "reportId": "report-detail", "reportName": "Title", "templateName": "Template", "senderName": "Creator", + "create_time": int(4), "modified_time": json.Number("5"), + "report_content": []any{map[string]any{"key": "field", "value": "", "sort": int(1), "type": int64(2)}}, + }} + detail, err := reportProjectDetail(completeDetail, operation, "report-detail") + if err != nil || detail["modifiedTime"] != int64(5) || len(detail["fields"].([]map[string]any)) != 1 { + t.Fatalf("complete detail=%#v err=%v", detail, err) + } + for name, fixture := range map[string]map[string]any{ + "bad optional string": {"success": true, "result": map[string]any{"reportId": "report-detail", "reportName": 1, "report_content": []any{}}}, + "bad optional integer": {"success": true, "result": map[string]any{"reportId": "report-detail", "create_time": "4", "report_content": []any{}}}, + "missing field value": {"success": true, "result": map[string]any{"reportId": "report-detail", "report_content": []any{map[string]any{"key": "field", "sort": 1, "type": 2}}}}, + } { + if projected, projectErr := reportProjectDetail(fixture, operation, "report-detail"); projectErr == nil { + t.Errorf("%s returned success: %#v", name, projected) + } + } +} + +func TestCrossPlatformCoverageReportValidationHelpersCoverAllContracts(t *testing.T) { + if _, err := reportParseISOMillis("start", "not-a-time"); err == nil { + t.Fatal("invalid ISO timestamp returned success") + } + if err := reportValidatePage(-1, 20); err == nil { + t.Fatal("negative cursor returned success") + } + if err := reportValidatePage(0, 0); err == nil { + t.Fatal("zero page size returned success") + } + if err := reportValidatePage(0, 20); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name, start, end string + maximumDays int + }{ + {"bad start", "bad", "2026-07-02T00:00:00+08:00", 20}, + {"bad end", "2026-07-01T00:00:00+08:00", "bad", 20}, + {"too wide", "2026-07-01T00:00:00+08:00", "2026-08-01T00:00:00+08:00", 20}, + } { + if _, _, err := reportValidateRange("start", test.start, "end", test.end, test.maximumDays); err == nil { + t.Errorf("%s returned success", test.name) + } + } + if _, _, err := reportValidateRange("start", "2026-07-01T00:00:00+08:00", "end", "2026-08-01T00:00:00+08:00", 0); err != nil { + t.Fatalf("unbounded valid range: %v", err) + } +} + +func TestCrossPlatformCoverageReportExecutorsSuccessErrorsAndZeroCalls(t *testing.T) { + start := "2026-07-01T00:00:00+08:00" + end := "2026-07-02T00:00:00+08:00" + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]} ,"hasMore":false}` + + inbox := &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {validList}}} + if _, err := runReportCoverage(t, InboxList, inbox, "--start", start, "--end", end, "--sender-user-ids", "user-1,user-2"); err != nil { + t.Fatal(err) + } + if len(inbox.history) != 1 || len(inbox.history[0].args["senderUserIds"].([]string)) != 2 { + t.Fatalf("inbox args/history=%#v", inbox.history) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, []string{"--start", start, "--end", end}}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true}`}}}, []string{"--start", start, "--end", end}}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_received_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--start", start, "--end", end, "--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, InboxList, test.caller, test.args...); err == nil { + t.Errorf("inbox %s returned success", test.name) + } + } + directInbox := directReportRuntime(t, InboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := InboxList.Execute(directInbox); err == nil { + t.Fatal("direct inbox invalid range returned success") + } + + outboxAll := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-2","createTime":2}]},"hasMore":true,"nextCursor":3}`}}} + if _, err := runReportCoverage(t, OutboxList, outboxAll, + "--cursor", "2", "--start", start, "--end", end, + "--modified-start", start, "--modified-end", end, "--template-name", " Weekly "); err != nil { + t.Fatal(err) + } + if len(outboxAll.history) != 1 || outboxAll.history[0].args["report_template_name"] != "Weekly" || outboxAll.history[0].args["modifiedStartTime"] == nil { + t.Fatalf("outbox args/history=%#v", outboxAll.history) + } + defaultOutbox := &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {validList}}} + if _, err := runReportCoverage(t, OutboxList, defaultOutbox); err != nil { + t.Fatal(err) + } + for _, test := range []struct { + name string + caller *reportCoverageCaller + args []string + }{ + {"call error", &reportCoverageCaller{responses: map[string][]string{}}, nil}, + {"projection error", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true}`}}}, nil}, + {"stalled cursor", &reportCoverageCaller{responses: map[string][]string{"get_send_report_list": {`{"success":true,"result":{"report_list":[{"reportId":"report-1"}]},"hasMore":true,"nextCursor":2}`}}}, []string{"--cursor", "2"}}, + } { + if _, err := runReportCoverage(t, OutboxList, test.caller, test.args...); err == nil { + t.Errorf("outbox %s returned success", test.name) + } + } + directOutbox := directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, "--start", "bad", "--end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad creation range returned success") + } + directOutbox = directReportRuntime(t, OutboxList, &reportCoverageCaller{responses: map[string][]string{}}, + "--modified-start", "bad", "--modified-end", end) + if err := OutboxList.Execute(directOutbox); err == nil { + t.Fatal("direct outbox bad modified range returned success") + } + + for _, test := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {"outbox invalid page", OutboxList, []string{"--size", "0"}}, + {"outbox invalid creation range", OutboxList, []string{"--start", "bad", "--end", end}}, + {"outbox valid modified range", OutboxList, []string{"--modified-start", start, "--modified-end", end}}, + {"template empty query", TemplateSearch, []string{"--query", " "}}, + } { + caller := &reportCoverageCaller{responses: map[string][]string{}} + _, err := runReportCoverage(t, test.declaration, caller, test.args...) + if test.name == "outbox valid modified range" { + if err == nil || len(caller.history) != 1 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + continue + } + if err == nil || len(caller.history) != 0 { + t.Errorf("%s err=%v history=%#v", test.name, err, caller.history) + } + } + + for name, caller := range map[string]*reportCoverageCaller{ + "call error": {responses: map[string][]string{}}, + "projection error": {responses: map[string][]string{"get_available_report_templates": {`{"success":true}`}}}, + } { + if _, err := runReportCoverage(t, TemplateSearch, caller, "--query", "weekly"); err == nil { + t.Errorf("template %s returned success", name) + } + } + directTemplate := directReportRuntime(t, TemplateSearch, &reportCoverageCaller{responses: map[string][]string{}}, "--query", " ") + if err := TemplateSearch.Validate(directTemplate); err == nil { + t.Fatal("direct template whitespace query returned success") + } + legacyTemplate := TemplateSearch + legacyTemplate.OutputRollout = output.RolloutLegacyOnly + legacyTemplateCaller := &reportCoverageCaller{responses: map[string][]string{"get_available_report_templates": {`{"success":true,"items":[]}`}}} + if _, err := runReportCoverage(t, legacyTemplate, legacyTemplateCaller, "--query", "zero"); err != nil { + t.Fatalf("legacy template: %v", err) + } +} + +func TestCrossPlatformCoverageReportLatestExecutorRemainingBranches(t *testing.T) { + validList := `{"success":true,"result":{"report_list":[{"reportId":"report-1","createTime":1}]},"hasMore":false}` + validDetail := `{"success":true,"result":{"report_Id":"report-1","report_content":[]}}` + defaultCaller := &reportCoverageCaller{responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {validDetail}, + }} + if _, err := runReportCoverage(t, ReportLatest, defaultCaller, "--keyword", " Weekly "); err != nil { + t.Fatal(err) + } + if defaultCaller.history[0].args["report_template_name"] != "Weekly" { + t.Fatalf("latest keyword args=%#v", defaultCaller.history[0].args) + } + + for name, caller := range map[string]*reportCoverageCaller{ + "list call error": {responses: map[string][]string{}}, + "list projection error": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true}`}, + }}, + "empty candidates": {responses: map[string][]string{ + "get_send_report_list": {`{"success":true,"result":{"report_list":[]},"hasMore":false}`}, + }}, + "detail call error": {responses: map[string][]string{ + "get_send_report_list": {validList}, + }}, + "detail projection error": {responses: map[string][]string{ + "get_send_report_list": {validList}, "get_report_entry_details": {`{"success":true}`}, + }}, + } { + if _, err := runReportCoverage(t, ReportLatest, caller); err == nil { + t.Errorf("latest %s returned success", name) + } + } + directLatest := directReportRuntime(t, ReportLatest, &reportCoverageCaller{responses: map[string][]string{}}, + "--start", "bad", "--end", "2026-07-02T00:00:00+08:00") + if err := ReportLatest.Execute(directLatest); err == nil { + t.Fatal("direct latest invalid range returned success") + } + if _, err := reportLatestEntryID([]map[string]any{{"createTime": int64(1), "reportId": 1}}, "report/latest"); err == nil { + t.Fatal("non-string latest report identity returned success") + } +} + +func TestCrossPlatformCoverageReportOutputLegacyAndInvalidPagination(t *testing.T) { + command := &cobra.Command{Use: "report-page"} + command.SetOut(io.Discard) + command.SetContext(context.Background()) + declaration := shortcut.Shortcut{Service: "report", Product: "report"} + rt := shortcut.RuntimeContextForTest(command, declaration) + output.SetCommandRollout(command, output.RolloutLegacyOnly) + if err := outputReportPage(rt, "reports", []map[string]any{{"reportId": "report-1"}}, reportPageEvidence{HasMore: true, Next: "2"}); err != nil { + t.Fatalf("legacy report page: %v", err) + } + + ctx, _ := output.WithResultStore(context.Background()) + command.SetContext(ctx) + output.SetCommandRollout(command, output.RolloutUnifiedActive) + if err := outputReportPage(rt, "reports", nil, reportPageEvidence{HasMore: true}); err == nil { + t.Fatal("unified page without continuation returned success") + } +} diff --git a/internal/shortcut/responsecheck/responsecheck.go b/internal/shortcut/responsecheck/responsecheck.go new file mode 100644 index 00000000..1b175933 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck.go @@ -0,0 +1,181 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +// Package responsecheck contains fail-closed response validators shared by +// built-in shortcuts. It deliberately does not guess that a missing collection +// means an empty result: callers must name the exact reviewed wire paths. +package responsecheck + +import ( + "fmt" + "strings" + + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" +) + +// Error returns a non-retryable response-validation error. The remote request +// has completed, but the response is not sufficient evidence of success. +func Error(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +// RequireSuccess validates the common service envelope and returns it. A +// missing success field is rejected because the products using this helper +// have been observed to publish an explicit boolean success field. +func RequireSuccess(data map[string]any, operation string) (map[string]any, error) { + if len(data) == 0 { + return nil, Error(operation, "empty_tool_response", "服务返回空响应,无法证明调用成功或结果确实为空") + } + envelope := data + if content, ok := data["content"].(map[string]any); ok { + envelope = content + } + raw, present := envelope["success"] + if !present { + return nil, Error(operation, "missing_success", "响应缺少 success 业务状态,无法证明调用成功") + } + success, ok := raw.(bool) + if !ok { + return nil, Error(operation, "malformed_success", "响应 success 字段不是布尔值") + } + if !success { + message := firstString(envelope, "errorMessage", "errorMsg", "message", "error") + if message == "" { + message = "服务明确返回 success=false" + } + return nil, Error(operation, "remote_failure", message) + } + return envelope, nil +} + +// RequireResult returns the explicit non-null result value from a successful +// response. Empty arrays and empty objects remain valid values; absence/null do +// not prove an empty business result. +func RequireResult(data map[string]any, operation string) (any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + result, present := envelope["result"] + if !present || result == nil { + return nil, Error(operation, "missing_result", "成功响应缺少非空 result 字段") + } + return result, nil +} + +// RequireObjectResult returns an explicit object result. +func RequireObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + object, ok := result.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("响应 result 应为对象,实际为 %T", result)) + } + return object, nil +} + +// RequireSingleObjectResult returns one non-empty business object. It accepts +// the two reviewed detail shapes used by product services: result={...} and +// result=[{...}]. Empty arrays, multiple rows, empty objects, and scalar values +// cannot prove that the requested unique resource was read. +func RequireSingleObjectResult(data map[string]any, operation string) (map[string]any, error) { + result, err := RequireResult(data, operation) + if err != nil { + return nil, err + } + var object map[string]any + switch typed := result.(type) { + case map[string]any: + object = typed + case []any: + if len(typed) != 1 { + return nil, Error(operation, "unexpected_detail_count", fmt.Sprintf("详情响应应唯一,实际返回 %d 项", len(typed))) + } + var ok bool + object, ok = typed[0].(map[string]any) + if !ok { + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result[0] 应为对象,实际为 %T", typed[0])) + } + default: + return nil, Error(operation, "malformed_result", fmt.Sprintf("详情响应 result 应为对象或单元素对象数组,实际为 %T", result)) + } + if len(object) == 0 { + return nil, Error(operation, "empty_result_object", "成功响应的详情对象为空,无法证明资源读取成功") + } + return object, nil +} + +// RequireObjectCollection resolves the first present reviewed path, requires +// an array at that exact path, and rejects malformed elements instead of +// silently dropping them. A present empty array is a valid empty result. +func RequireObjectCollection(data map[string]any, operation string, paths ...string) ([]map[string]any, error) { + envelope, err := RequireSuccess(data, operation) + if err != nil { + return nil, err + } + for _, path := range paths { + value, present := lookup(envelope, path) + if !present { + continue + } + raw, ok := value.([]any) + if !ok { + return nil, Error(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, Error(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil + } + return nil, Error(operation, "missing_collection", "成功响应缺少已审核的结果数组;不能把未知响应结构当作空结果") +} + +// LookupObject returns the object at a reviewed dotted path. +func LookupObject(data map[string]any, operation, path string) (map[string]any, error) { + value, present := lookup(data, path) + if !present { + return nil, Error(operation, "missing_object", fmt.Sprintf("响应缺少 %s 对象", path)) + } + object, ok := value.(map[string]any) + if !ok { + return nil, Error(operation, "malformed_object", fmt.Sprintf("响应 %s 应为对象,实际为 %T", path, value)) + } + return object, nil +} + +func lookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func firstString(object map[string]any, keys ...string) string { + for _, key := range keys { + if value, ok := object[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/internal/shortcut/responsecheck/responsecheck_test.go b/internal/shortcut/responsecheck/responsecheck_test.go new file mode 100644 index 00000000..41a5c3c2 --- /dev/null +++ b/internal/shortcut/responsecheck/responsecheck_test.go @@ -0,0 +1,161 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package responsecheck + +import "testing" + +func TestCrossPlatformCoverageRequireObjectCollection(t *testing.T) { + tests := []struct { + name string + data map[string]any + wantLen int + wantErr bool + }{ + {name: "non-empty", data: map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}, wantLen: 1}, + {name: "content envelope", data: map[string]any{"content": map[string]any{"success": true, "result": []any{map[string]any{"id": "one"}}}}, wantLen: 1}, + {name: "explicit empty", data: map[string]any{"success": true, "result": []any{}}, wantLen: 0}, + {name: "missing reviewed path", data: map[string]any{"success": true}, wantErr: true}, + {name: "missing collection", data: map[string]any{"success": true, "result": map[string]any{}}, wantErr: true}, + {name: "wrong collection type", data: map[string]any{"success": true, "result": "bad"}, wantErr: true}, + {name: "malformed item", data: map[string]any{"success": true, "result": []any{"bad"}}, wantErr: true}, + {name: "empty item", data: map[string]any{"success": true, "result": []any{map[string]any{}}}, wantErr: true}, + {name: "missing success", data: map[string]any{"result": []any{}}, wantErr: true}, + {name: "remote failure", data: map[string]any{"success": false, "result": []any{}}, wantErr: true}, + {name: "empty response", data: map[string]any{}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := RequireObjectCollection(tc.data, "test/read", "result") + if (err != nil) != tc.wantErr { + t.Fatalf("error = %v, wantErr %v", err, tc.wantErr) + } + if err == nil && len(got) != tc.wantLen { + t.Fatalf("len = %d, want %d", len(got), tc.wantLen) + } + }) + } +} + +func TestCrossPlatformCoverageRequireObjectCollectionNested(t *testing.T) { + data := map[string]any{ + "success": true, + "result": map[string]any{ + "items": []any{map[string]any{"id": "one"}}, + }, + } + items, err := RequireObjectCollection(data, "test/nested", "result.items") + if err != nil { + t.Fatalf("RequireObjectCollection: %v", err) + } + if len(items) != 1 { + t.Fatalf("items = %#v", items) + } +} + +func TestCrossPlatformCoverageRequireResultRejectsNull(t *testing.T) { + if _, err := RequireResult(map[string]any{"success": true, "result": nil}, "test/null"); err == nil { + t.Fatal("expected null result to fail closed") + } + if _, err := RequireObjectResult(map[string]any{"success": true, "result": []any{}}, "test/object"); err == nil { + t.Fatal("expected array result to fail object validation") + } +} + +func TestCrossPlatformCoverageRequireSingleObjectResult(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": map[string]any{"id": "one"}}, + {"success": true, "result": []any{map[string]any{"id": "one"}}}, + } { + object, err := RequireSingleObjectResult(data, "test/detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid detail shape rejected: object=%v err=%v", object, err) + } + } + for _, data := range []map[string]any{ + {}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": []any{}}, + {"success": true, "result": []any{map[string]any{"id": "one"}, map[string]any{"id": "two"}}}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": "bad"}, + } { + if object, err := RequireSingleObjectResult(data, "test/detail"); err == nil { + t.Fatalf("ambiguous/malformed detail returned success: %v", object) + } + } +} + +func TestCrossPlatformCoverageResponseEnvelopeFailures(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + }{ + {name: "missing success", data: map[string]any{"result": map[string]any{}}}, + {name: "malformed success", data: map[string]any{"success": "true"}}, + {name: "failure with explicit message", data: map[string]any{"success": false, "errorMsg": " reviewed failure "}}, + {name: "failure without message", data: map[string]any{"success": false, "error": 42}}, + } { + t.Run(tc.name, func(t *testing.T) { + if _, err := RequireSuccess(tc.data, "test/envelope"); err == nil { + t.Fatal("expected fail-closed envelope validation") + } + }) + } + + if _, err := RequireResult(map[string]any{}, "test/result"); err == nil { + t.Fatal("expected upstream envelope failure") + } + if _, err := RequireResult(map[string]any{"success": true}, "test/result"); err == nil { + t.Fatal("expected missing result failure") + } + if object, err := RequireObjectResult(map[string]any{"success": true, "result": map[string]any{"id": "one"}}, "test/object"); err != nil || object["id"] != "one" { + t.Fatalf("valid object result rejected: object=%v err=%v", object, err) + } + if _, err := RequireObjectResult(map[string]any{}, "test/object"); err == nil { + t.Fatal("expected upstream object envelope failure") + } +} + +func TestCrossPlatformCoverageLookupObject(t *testing.T) { + data := map[string]any{ + "result": map[string]any{ + "detail": map[string]any{"id": "one"}, + "scalar": "bad", + }, + } + object, err := LookupObject(data, "test/lookup", "result.detail") + if err != nil || object["id"] != "one" { + t.Fatalf("valid nested object rejected: object=%v err=%v", object, err) + } + if _, err := LookupObject(data, "test/lookup", "result.missing"); err == nil { + t.Fatal("expected missing nested object failure") + } + if _, err := LookupObject(data, "test/lookup", "result.scalar"); err == nil { + t.Fatal("expected malformed nested object failure") + } + if _, present := lookup(map[string]any{"result": "scalar"}, "result.detail"); present { + t.Fatal("scalar parent must not satisfy a nested lookup") + } +} + +func TestCrossPlatformCoverageCollectionPathFallbackAndHelpers(t *testing.T) { + items, err := RequireObjectCollection( + map[string]any{"success": true, "result": map[string]any{"items": []any{map[string]any{"id": "one"}}}}, + "test/fallback", + "result.records", + "result.items", + ) + if err != nil || len(items) != 1 { + t.Fatalf("reviewed path fallback failed: items=%v err=%v", items, err) + } + if _, err := RequireObjectCollection(map[string]any{}, "test/fallback", "result"); err == nil { + t.Fatal("expected collection envelope failure") + } + if got := firstString(map[string]any{"first": 7, "second": " ", "third": " value "}, "first", "second", "third"); got != "value" { + t.Fatalf("firstString = %q, want value", got) + } + if got := firstString(map[string]any{"only": " "}, "only"); got != "" { + t.Fatalf("firstString whitespace = %q, want empty", got) + } +} diff --git a/internal/shortcut/semantic_catalog.go b/internal/shortcut/semantic_catalog.go index fbb5b312..60aa42c6 100644 --- a/internal/shortcut/semantic_catalog.go +++ b/internal/shortcut/semantic_catalog.go @@ -34,6 +34,21 @@ var calendarSemanticCatalogJSON []byte //go:embed semantic_catalog_todo.json var todoSemanticCatalogJSON []byte +//go:embed semantic_catalog_attendance.json +var attendanceSemanticCatalogJSON []byte + +//go:embed semantic_catalog_mail.json +var mailSemanticCatalogJSON []byte + +//go:embed semantic_catalog_oa.json +var oaSemanticCatalogJSON []byte + +//go:embed semantic_catalog_ding.json +var dingSemanticCatalogJSON []byte + +//go:embed semantic_catalog_report.json +var reportSemanticCatalogJSON []byte + type semanticCatalogFile struct { Version int `json:"version"` Service string `json:"service"` @@ -42,13 +57,14 @@ type semanticCatalogFile struct { } type semanticCatalogRecord struct { - Disposition SemanticDisposition `json:"disposition"` - SemanticDelta string `json:"semantic_delta"` - Risk Risk `json:"risk"` - Availability Availability `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Public bool `json:"public"` - Reviewed bool `json:"reviewed"` + Disposition SemanticDisposition `json:"disposition"` + SemanticDelta string `json:"semantic_delta"` + Risk Risk `json:"risk"` + Availability Availability `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Public bool `json:"public"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` + Reviewed bool `json:"reviewed"` } var reviewedSemanticCatalog = mustLoadSemanticCatalogs( @@ -60,6 +76,11 @@ var reviewedSemanticCatalog = mustLoadSemanticCatalogs( wikiSemanticCatalogJSON, calendarSemanticCatalogJSON, todoSemanticCatalogJSON, + attendanceSemanticCatalogJSON, + mailSemanticCatalogJSON, + oaSemanticCatalogJSON, + dingSemanticCatalogJSON, + reportSemanticCatalogJSON, ) func mustLoadSemanticCatalogs(sources ...[]byte) map[string]semanticCatalogRecord { @@ -120,6 +141,14 @@ func loadSemanticCatalog(raw []byte, out map[string]semanticCatalogRecord) { panic(fmt.Sprintf("semantic catalog command %q cannot be public with availability %q", command, record.Availability)) } + // A command that was already part of the visible CLI contract cannot be + // hidden merely because Agent publication is withdrawn. Compatibility + // visibility owns only historical CLI discovery; availability independently + // records whether that compatibility path still executes. public=false keeps + // both available and unavailable compatibility leaves out of Agent routes. + if record.CompatibilityVisible && record.Public { + panic(fmt.Sprintf("semantic catalog command %q cannot be both public and compatibility-visible", command)) + } key := publicCatalogKey(source.Service, command) if _, exists := out[key]; exists { panic(fmt.Sprintf("duplicate shortcut semantic catalog entry %s %s", source.Service, command)) @@ -138,6 +167,7 @@ func applyReviewedSemanticCatalog(s Shortcut) (Shortcut, bool) { s.Availability = record.Availability s.PrimaryCommand = record.Primary s.SemanticReviewed = record.Reviewed - s.Hidden = !record.Public + s.CompatibilityVisible = record.CompatibilityVisible + s.Hidden = !record.Public && !record.CompatibilityVisible return s, true } diff --git a/internal/shortcut/semantic_catalog_attendance.json b/internal/shortcut/semantic_catalog_attendance.json new file mode 100644 index 00000000..ab478fcf --- /dev/null +++ b/internal/shortcut/semantic_catalog_attendance.json @@ -0,0 +1,44 @@ +{ + "version": 1, + "service": "attendance", + "default_availability": "unavailable", + "shortcuts": { + "+check-result": {"disposition":"semantic_adapter","semantic_delta":"对齐 Lark attendance user_tasks query;严格校验正整数 ID 及请求用户/日期绑定,以 cursor_parameter=offset 将保守续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+check-record": {"disposition":"semantic_adapter","semantic_delta":"查询原始打卡流水;只有显式成功数组可表示空结果,每条必须有唯一正整数 ID 并绑定请求用户和日期。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+list-approve": {"disposition":"semantic_adapter","semantic_delta":"把审批类型语义映射为业务枚举;每条审批必须有唯一正整数 ID,并精确绑定请求用户、类型与时间范围。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-approve-template": {"disposition":"semantic_adapter","semantic_delta":"把补卡、请假、加班、外出、出差映射为审批模板类型;每项以非空唯一 processCode 作为稳定身份,approveType 精确绑定请求且 submitUrl 必须非空,允许同类型返回多个模板。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-schedule": {"disposition":"semantic_adapter","semantic_delta":"两次独立 clean HEAD 的已知非空与保证零命中请求中,getScheduleByRange 均以退出码 0 返回 literal null;Shortcut 正确非零拒绝,但下游没有提供可验证的显式排班数组或 typed error,修复前不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-class": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.items、展开 shiftVO、要求唯一正整数 classId,并将无矛盾的 totalCount/totalPage 续页证据发布到 meta.pagination。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-class": {"disposition":"schema_leaf","semantic_delta":"真实搜索 classId 可取得非空详情,但 get_class_detail 的 shiftVO 不回显 id/classId,无法诚实证明详情属于请求 ID;等待下游补充稳定 ID 回显。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+search-adjustment-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.adjustmentList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-overtime-rule": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.atRuleList、展开 entityVO、要求稳定规则 ID,并公开分页完整性证据。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-overtime-rule": {"disposition":"schema_leaf","semantic_delta":"使用搜索得到的加班规则 ID 读取详情,严格拒绝空 result,且响应 id 必须与请求 overtimeId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+get-summary": {"disposition":"schema_leaf","semantic_delta":"真实响应只返回统计项,未回显请求用户、周期或统计类型,无法将结果精确绑定到请求;在下游补齐身份回显前保持不可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-self-setting": {"disposition":"schema_leaf","semantic_delta":"前五个设置场景可严格绑定用户和返回类型,但 bossAttendStatNotify 在当前安全身份下返回 NO_PERMISSION;缺少 capability discovery、逐场景权限合同和安全权限 fixture,暂不公开。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-leave-types": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有非空假期类型列表且命令没有筛选参数;缺少合法空结果专用租户,无法完成 empty/nonempty 双态发布证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-leave-records": {"disposition":"semantic_adapter","semantic_delta":"使用真实用户和已发现 leaveCode 只能得到合法空集合;缺少已知非空变更流水 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+get-checkin-record": {"disposition":"semantic_adapter","semantic_delta":"当前安全身份只有合法空签到结果;缺少已知非空签到 fixture,无法排除空结果假阳性。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+my-attendance": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的今天时间窗只返回合法空集合;缺少已知非空当天打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+this-month": {"disposition":"primary_smart","semantic_delta":"当前安全身份在固定的本月时间窗只返回合法空集合;缺少已知非空本月打卡 fixture,无法完成 exact Shortcut 非空证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + + "+get-adjustment-rule": {"disposition":"schema_leaf","semantic_delta":"真实搜索返回规则,但详情接口对搜索得到的候选主键明确 success=true 且 result=null;在下游修复主键或详情返回前不可宣称可用。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+search-group": {"disposition":"semantic_adapter","semantic_delta":"严格投影已实现,但当前安全账号只有显式空结果,缺少已知非空考勤组 fixture,不能完成真实数据证明。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+query-report-data": {"disposition":"semantic_adapter","semantic_delta":"报表列发现接口返回 null,无法取得已验证 columnId;使用未验证列只得到空数组,不能证明报表查询正确。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+list-report-columns": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,既不是列数组也不是可证明的合法空结果。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+query-report-leave": {"disposition":"schema_leaf","semantic_delta":"下游接口当前以退出码 0 返回 null,无法证明假期报表任务成功或确实为空。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-global-setting": {"disposition":"schema_leaf","semantic_delta":"读取企业全局设置需要当前测试身份没有的管理员权限;缺少最小权限 fixture 前保持不可用。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-leave-balance": {"disposition":"schema_leaf","semantic_delta":"使用真实用户和已发现假期编码仍无法得到可验证结果;下游需补齐稳定响应契约或可控余额 fixture。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group": {"disposition":"schema_leaf","semantic_delta":"详情读取需要稳定考勤组 ID;当前搜索没有已知非空安全 fixture,不能完成精确身份读回。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + "+get-group-filtered": {"disposition":"schema_leaf","semantic_delta":"过滤详情同样依赖稳定考勤组 ID;当前没有安全非空 fixture 可验证字段选择和身份绑定。","risk":"read","availability":"unavailable","public":false,"reviewed":true}, + + "+import-schedule": {"disposition":"schema_leaf","semantic_delta":"会改写员工排班;缺少隔离考勤组、班次、成员和写后读回/清理 fixture,不能安全开放。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-class": {"disposition":"schema_leaf","semantic_delta":"会创建企业班次;缺少隔离命名、稳定创建回执、精确详情读回和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-class": {"disposition":"schema_leaf","semantic_delta":"会影响使用该班次的员工;缺少可恢复测试班次和严格更新后读回证据。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group-members": {"disposition":"schema_leaf","semantic_delta":"会改变考勤组成员范围;缺少隔离成员 fixture、逐项结果和可验证回滚。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+create-group": {"disposition":"schema_leaf","semantic_delta":"会创建考勤组并影响打卡规则;缺少安全班次、成员、稳定回执和删除清理能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-group": {"disposition":"schema_leaf","semantic_delta":"会修改考勤组规则;当前无安全非空考勤组 fixture 和完整读回/恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+update-leave-type": {"disposition":"schema_leaf","semantic_delta":"会修改企业假期规则并影响适用员工;缺少隔离假期类型和精确恢复路径。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+save-leave-balance": {"disposition":"schema_leaf","semantic_delta":"会直接改写员工假期余额;缺少隔离员工、幂等写入语义、余额读回和补偿恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+boss-check": {"disposition":"schema_leaf","semantic_delta":"会直接改写打卡结果;缺少隔离排班/结果 fixture、强确认、精确终态读回和恢复能力。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_coverage_test.go b/internal/shortcut/semantic_catalog_coverage_test.go index 61d00569..df822179 100644 --- a/internal/shortcut/semantic_catalog_coverage_test.go +++ b/internal/shortcut/semantic_catalog_coverage_test.go @@ -121,6 +121,21 @@ func TestCrossPlatformCoverageSemanticCatalogRejectsInvalidRecords(t *testing.T) } } }`, + "compatibility-visible public": `{ + "version": 1, + "service": "chat", + "default_availability": "available", + "shortcuts": { + "+messages": { + "disposition": "semantic_adapter", + "semantic_delta": "reviewed", + "risk": "read", + "public": true, + "compatibility_visible": true, + "reviewed": true + } + } + }`, } original := semanticCatalogJSON t.Cleanup(func() { semanticCatalogJSON = original }) @@ -172,6 +187,38 @@ func TestCrossPlatformCoveragePublicCatalogSemanticAndGeneratedLookups(t *testin } } +func TestCrossPlatformCoverageCompatibilityVisibleStaysNonPublic(t *testing.T) { + item, ok := applyReviewedSemanticCatalog(Shortcut{Service: "attendance", Command: "+get-summary"}) + if !ok || item.Hidden || !item.CompatibilityVisible || item.Availability != AvailabilityUnavailable { + t.Fatalf("compatibility-visible semantic item = %#v, found=%v", item, ok) + } + if InPublicCatalog(item.Service, item.Command) { + t.Fatal("compatibility-visible unavailable shortcut entered the public catalog") + } + + available := map[string]semanticCatalogRecord{} + loadSemanticCatalog([]byte(`{ + "version":1, + "service":"compatibility-test", + "default_availability":"unavailable", + "shortcuts":{ + "+legacy":{ + "disposition":"semantic_adapter", + "semantic_delta":"historical CLI path remains executable but is not Agent-public", + "risk":"write", + "availability":"available", + "public":false, + "compatibility_visible":true, + "reviewed":true + } + } + }`), available) + record := available[publicCatalogKey("compatibility-test", "+legacy")] + if record.Public || !record.CompatibilityVisible || record.Availability != AvailabilityAvailable { + t.Fatalf("available compatibility record = %#v", record) + } +} + func TestCrossPlatformCoverageRuntimeReadDataBranches(t *testing.T) { caller := &runtimeReadCoverageCaller{} old := helpers.GetCaller() diff --git a/internal/shortcut/semantic_catalog_ding.json b/internal/shortcut/semantic_catalog_ding.json new file mode 100644 index 00000000..7c1565d5 --- /dev/null +++ b/internal/shortcut/semantic_catalog_ding.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "service": "ding", + "default_availability": "unavailable", + "shortcuts": { + "+list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.dingMessages、稳定 openDingId 与 hasMore/nextCursor;exact Shortcut 已完成已知非空与保证零项证明,但真实续页返回停滞 cursor,严格实现拒绝虚假分页并保持不可用。", + "risk": "read", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+receiver-status": { + "disposition": "semantic_adapter", + "semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+send-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行,并修正 app/sms/call 到 APP/SMS/PHONE 的下游枚举映射;因接收项没有稳定接收人身份且撤回无可查询终态,不进入 Agent 公共发现。", + "risk": "write", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+send-by-message": { + "disposition": "semantic_adapter", + "semantic_delta": "覆盖基于消息的应用内、短信和电话 DING并映射 lark-cli 三种 urgent 任务;隔离 APP fixture 已证明源消息、稳定 DING 回执和目标 DING 读回,但接收人身份与撤回终态仍不可验证,保持不可用。", + "risk": "write", + "public": false, + "reviewed": true + }, + "+recall-personal": { + "disposition": "semantic_adapter", + "semantic_delta": "历史 CLI 兼容入口在用户确认后保持可执行;个人 DING 撤回只返回布尔成功且不回显终态,不能作为 Agent 可验证写能力,因此不进入公共发现。", + "risk": "high-risk-write", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_mail.json b/internal/shortcut/semantic_catalog_mail.json new file mode 100644 index 00000000..8e3b260a --- /dev/null +++ b/internal/shortcut/semantic_catalog_mail.json @@ -0,0 +1,25 @@ +{ + "version": 1, + "service": "mail", + "default_availability": "unavailable", + "shortcuts": { + "+triage": {"disposition":"primary_smart","semantic_delta":"对齐 Lark triage 任务入口,并超过其显式邮箱要求:可自动解析当前邮箱与收件箱,严格处理邮件搜索的终止游标和零命中哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+search-mail": {"disposition":"semantic_adapter","semantic_delta":"用 KQL 搜索邮件摘要;只接受已观测 messages 数组、稳定 messageId 和完整分页证据,窄化支持 Mail 专属 total=0 占位哨兵。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+unread-mail": {"disposition":"primary_smart","semantic_delta":"固定 isRead:false 查询缺少可控 guaranteed-zero 条件;当前没有专用空邮箱 fixture,且不得修改真实邮件已读状态制造空结果,因此保持 unavailable。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+recent-mail": {"disposition":"primary_smart","semantic_delta":"严格读取 result.conversations、稳定会话 ID 与分页证据;但当前全部可见文件夹都有会话,缺少已验证空文件夹 fixture,无法完成双态发布审计。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+message": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 单封完整读入口;自动解析邮箱,要求顶层 message 非空且返回 ID 与请求 messageId 精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+messages": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 多封完整读入口;最多 100 个 ID,保持请求顺序,任何单封缺失、错型或身份不一致都会使整次调用失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread": {"disposition":"primary_smart","semantic_delta":"对齐 Lark 完整会话读取;自动解析邮箱并要求 conversation.id 与请求精确一致。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+thread-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 result.conversations、稳定 ID 和分页;但当前全部可见文件夹都有会话,且时间过滤未提供可靠零命中,缺少已验证空文件夹 fixture。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+folder-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 folders 数组及稳定 folder ID;缺字段、错型和坏元素不再退化为空列表。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+tag-list": {"disposition":"semantic_adapter","semantic_delta":"只接受显式 tags 数组及稳定 tag ID;但入口没有 query/parent 过滤且没有专用空邮箱 fixture,无法证明 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+user-search": {"disposition":"semantic_adapter","semantic_delta":"搜索企业邮箱用户并保留 hasMore/nextCursor;显式空数组合法,坏用户或缺稳定 ID 失败。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+find-mail-user": {"disposition":"primary_smart","semantic_delta":"面向联系人解析的精简用户搜索;修复零命中被误报为调用失败,并严格验证用户数组、身份和分页。","risk":"read","availability":"available","public":true,"reviewed":true}, + "+template-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 templates 数组、稳定模板 ID 和 nextCursor;但入口没有 query,模板删除后也没有 typed nonfound,不能把列表消失当 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+contact-list": {"disposition":"semantic_adapter","semantic_delta":"严格读取 contacts 数组、稳定联系人 ID 和分页;但入口没有 query,且 create/delete 缺稳定身份闭环,无法安全构造 guaranteed-zero。","risk":"read","availability":"unavailable","public":false,"compatibility_visible":true,"reviewed":true}, + "+draft-create": {"disposition":"primary_smart","semantic_delta":"已实现确认、稳定 messageId 回执和精确读回;但真实 fixture 连续两次 batch-delete 后仍能按同 ID 读取,无法证明安全清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+draft-edit": {"disposition":"primary_smart","semantic_delta":"已实现至少一项变更、严格回执和按原 messageId 读回;但草稿删除后仍可读,缺少可隔离且可证明零残留的真实 fixture。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-create": {"disposition":"primary_smart","semantic_delta":"核心字段可按稳定模板 ID 读回,已移除无法验证的 from/isDraft 新入口;但 delete 后 get 仅返回未分类失败,没有 typed nonfound 证据可证明清理终态。","risk":"write","availability":"unavailable","public":false,"reviewed":true}, + "+template-update": {"disposition":"primary_smart","semantic_delta":"真实更新回执仅含明确 success,上层使用请求稳定 ID 精确读回核对变更;但模板删除后缺少 typed nonfound,无法证明安全 fixture 零残留。","risk":"write","availability":"unavailable","public":false,"reviewed":true} + } +} diff --git a/internal/shortcut/semantic_catalog_oa.json b/internal/shortcut/semantic_catalog_oa.json new file mode 100644 index 00000000..0aee7c46 --- /dev/null +++ b/internal/shortcut/semantic_catalog_oa.json @@ -0,0 +1,96 @@ +{ + "version": 1, + "service": "oa", + "default_availability": "unavailable", + "shortcuts": { + "+list-executed": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.values、稳定 processInstanceId 与 hasMore;known-nonempty exact/raw 的 7 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,收紧后拒绝,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+list-submitted": { + "disposition": "semantic_adapter", + "semantic_delta": "严格返回已发起审批实例及页完整性;known-nonempty exact/raw 的 14 个稳定身份一致,但 guaranteed-zero-query 的 raw 响应缺少 hasMore,不能把空数组推断为终页,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+my-initiated": { + "disposition": "alias_internal", + "semantic_delta": "保留历史 initiated 字段且不回退原始响应;known-nonempty exact/raw 的 14 个稳定身份一致,但复用的 guaranteed-zero raw 响应缺少 hasMore,故与主列表一并保持非公开。", + "risk": "read", + "availability": "available", + "primary": "+list-submitted", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+search-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+done-approvals": { + "disposition": "alias_internal", + "semantic_delta": "保留旧的首屏已处理审批摘要;已验证严格非空结果,但不提供页码,新的 Agent 路由统一使用 +list-executed。", + "risk": "read", + "availability": "available", + "primary": "+list-executed", + "public": false, + "reviewed": true + }, + "+list-forms": { + "disposition": "semantic_adapter", + "semantic_delta": "真实非空页不返回 continuation,且 cursor 0/1 的集合高度重叠;严格实现拒绝缺失、畸形、停滞或倒退游标,下游修复前不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+list-pending": { + "disposition": "semantic_adapter", + "semantic_delta": "严格待办投影与分页已实现;exact Shortcut 与同参数 atomic/raw 均隔离到 success=true 但 result.values=null,错型集合被拒绝,且当前没有安全非空待审批 fixture,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+pending": { + "disposition": "alias_internal", + "semantic_delta": "历史首屏待办入口路由到 +list-pending;同一真实响应的 result.values 为 null 而不是合法空数组,且当前没有已知非空安全 fixture,因此别名与主入口均保持非公开。", + "risk": "read", + "availability": "available", + "primary": "+list-pending", + "public": false, + "reviewed": true + }, + "+list-cc": { + "disposition": "semantic_adapter", + "semantic_delta": "严格抄送列表与分页校验已实现;exact Shortcut 和同参数 atomic/raw 隔离到错型业务状态与 null 集合,不能把响应解释为空页,且当前没有安全非空抄送 fixture,因此不进入 Agent 公开发现。", + "risk": "read", + "availability": "available", + "public": false, + "compatibility_visible": true, + "reviewed": true + }, + "+approve-by": { + "disposition": "primary_smart", + "semantic_delta": "唯一定位实例和任务、确认后写入、精确任务读回的严格编排已实现;缺少可撤销的待办审批 fixture 和可靠清理路径,因此不进入 Agent 公开发现。", + "risk": "high-risk-write", + "availability": "available", + "public": false, + "reviewed": true + } + } +} diff --git a/internal/shortcut/semantic_catalog_report.json b/internal/shortcut/semantic_catalog_report.json new file mode 100644 index 00000000..59f11983 --- /dev/null +++ b/internal/shortcut/semantic_catalog_report.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "service": "report", + "default_availability": "unavailable", + "shortcuts": { + "+inbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+outbox-list": { + "disposition": "semantic_adapter", + "semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+template-search": { + "disposition": "semantic_adapter", + "semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + }, + "+report-latest": { + "disposition": "primary_smart", + "semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。", + "risk": "read", + "availability": "available", + "public": true, + "reviewed": true + } + } +} diff --git a/internal/shortcut/smart/approve.go b/internal/shortcut/smart/approve.go deleted file mode 100644 index 9ad9a36b..00000000 --- a/internal/shortcut/smart/approve.go +++ /dev/null @@ -1,277 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "fmt" - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// Approve: agree to ONE of MY pending OA approvals by matching a keyword in its -// title / order number, in a single command (钉钉原生编排能力). -// -// Steps (all tool names + params copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals (starTime / endTime -// as float64 millis over a recent window, plus query=keyword for -// server-side narrowing — mirroring `dws oa approval list-pending`); -// -// 2. scan the returned instances and keep the ones whose title / order number -// contains --keyword: none → "没找到待审批单据", many → list candidates -// (标题 + processInstanceId) so the caller can be more specific, exactly one -// → take its processInstanceId; -// -// 3. resolve the pending taskId for that instance via list_pending_tasks -// (processInstanceId), then agree via approve_processInstance -// (processInstanceId + taskId as float64, optional remark) — -// mirroring `dws oa approval tasks` + `dws oa approval approve`. -// -// dws oa +approve-by --keyword 报销 -// dws oa +approve-by --keyword 出差单 --comment "同意" -var Approve = shortcut.Shortcut{ - Service: "oa", - Command: "+approve-by", - Product: "oa", - Description: "按关键词把我的一条待审批单据一键通过(自动定位实例与任务 ID)", - Intent: "当你只记得某张待你审批单据的标题或单号关键词、想直接把它审批通过,却不想先翻待办列表、" + - "复制审批实例 ID 再查任务 ID 时使用;内部先拉取你近三个月待处理的审批单,按标题/单号包含关键词匹配:" + - "没匹配到会提示「没找到待审批单据」,匹配到多条会列出候选(标题+实例ID)让你写得更精确," + - "唯一命中时先取出它的待审批任务 ID,再以「同意」提交。这会真实提交审批决策且不可撤回,请确认关键词足够精确。", - Risk: shortcut.RiskWrite, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "待审批单据的单号或标题关键词", Required: true}, - {Name: "comment", Type: shortcut.FlagString, Desc: "审批意见(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +approve-by --keyword 报销`, - `dws oa +approve-by --keyword 出差单 --comment "同意"`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - keyword := strings.TrimSpace(rt.Str("keyword")) - if keyword == "" { - return apperrors.NewValidation("请用 --keyword 提供待审批单据的单号或标题关键词") - } - - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds and query narrows server-side, mirroring - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - listArgs := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - "query": keyword, - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", listArgs) - if err != nil { - return err - } - - // Step 2 — match by title / order number substring. - matches := shortcutApproveMatch(data, keyword) - switch { - case len(matches) == 0: - return apperrors.NewValidation(fmt.Sprintf( - "没找到待审批单据:待我处理的审批里没有标题/单号包含 %q 的单据。", keyword)) - case len(matches) > 1: - return apperrors.NewValidation(fmt.Sprintf( - "%q 匹配到 %d 条待审批单据,请用更精确的关键词,或用 `dws oa approval approve --instance-id --task-id` 指定:%s", - keyword, len(matches), strings.Join(shortcutApproveLabels(matches), ";"))) - } - - instanceID := matches[0].instanceID - - // Step 3a — resolve the pending taskId for this instance via - // list_pending_tasks (processInstanceId), mirroring `dws oa approval tasks`. - tasksData, err := rt.CallMCPData("oa", "list_pending_tasks", map[string]any{ - "processInstanceId": instanceID, - }) - if err != nil { - return err - } - taskID := shortcutApproveTaskID(tasksData) - if taskID == "" { - return apperrors.NewValidation(fmt.Sprintf( - "没能拿到 %q 的待审批任务 ID,请用 `dws oa approval tasks --instance-id %s` 查看后手动 approve。", - matches[0].title, instanceID)) - } - - // Step 3b — agree. taskId is passed as float64 and remark is optional, - // mirroring helpers.approve (approve_processInstance). - taskIDNum, err := strconv.ParseFloat(taskID, 64) - if err != nil { - return apperrors.NewValidation(fmt.Sprintf("任务 ID %q 不是合法数字,无法审批。", taskID)) - } - approveArgs := map[string]any{ - "processInstanceId": instanceID, - "taskId": taskIDNum, - } - if c := strings.TrimSpace(rt.Str("comment")); c != "" { - approveArgs["remark"] = c - } - return rt.CallMCP("approve_processInstance", approveArgs) - }, -} - -// shortcutApproveInstance is the minimal identity we need from a pending-approval -// entry: its processInstanceId (fed to approve/tasks) and a human title/order -// number for matching + disambiguation. -type shortcutApproveInstance struct { - instanceID string - title string -} - -// shortcutApproveMatch walks a list_pending_approvals response and returns the -// instances whose title / order number contains keyword (case-insensitive). The -// gateway wraps the list under one of several common container keys, so we probe -// them defensively (mirroring latestMinutesItems / shortcutTodoCards). -func shortcutApproveMatch(data map[string]any, keyword string) []shortcutApproveInstance { - items := shortcutApproveItems(data) - kw := strings.ToLower(keyword) - var out []shortcutApproveInstance - seen := map[string]bool{} - for _, m := range items { - id := shortcutApproveInstanceID(m) - if id == "" || seen[id] { - continue - } - title := shortcutApproveTitle(m) - // Match on either the visible title/order number or the instance id. - if !strings.Contains(strings.ToLower(title), kw) && - !strings.Contains(strings.ToLower(id), kw) { - continue - } - seen[id] = true - if title == "" { - title = id - } - out = append(out, shortcutApproveInstance{instanceID: id, title: title}) - } - return out -} - -// shortcutApproveItems pulls the list of pending-approval entries out of the -// response, probing common container shapes before scanning for a bare list. -func shortcutApproveItems(data map[string]any) []map[string]any { - for _, key := range []string{"result", "list", "instances", "items", "data", "records", "processList"} { - if arr, ok := data[key].([]any); ok { - return shortcutApproveToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "items", "records", "result", "processList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutApproveToMaps(arr) - } - } - } - } - return nil -} - -func shortcutApproveToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutApproveInstanceID reads an entry's processInstanceId (the value the -// helper feeds to --instance-id), tolerating a few common field names and both -// string / numeric JSON encodings. -func shortcutApproveInstanceID(m map[string]any) string { - for _, key := range []string{"processInstanceId", "processInstanceID", "instanceId", "businessId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTitle reads a human-facing title / order number for matching -// and disambiguation, probing common field names. -func shortcutApproveTitle(m map[string]any) string { - for _, key := range []string{"title", "processTitle", "name", "subject", "businessId", "processCode"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveTaskID walks a list_pending_tasks response and returns the -// first pending taskId (the value the helper feeds to --task-id). -func shortcutApproveTaskID(data map[string]any) string { - items := shortcutApproveItems(data) - // list_pending_tasks may also return a bare {"result": [...]}/task list; the - // probe above covers those. Some gateways return a single task object. - if len(items) == 0 { - if id := shortcutApproveTaskIDField(data); id != "" { - return id - } - if r, ok := data["result"].(map[string]any); ok { - if id := shortcutApproveTaskIDField(r); id != "" { - return id - } - } - return "" - } - for _, m := range items { - if id := shortcutApproveTaskIDField(m); id != "" { - return id - } - } - return "" -} - -func shortcutApproveTaskIDField(m map[string]any) string { - for _, key := range []string{"taskId", "taskID", "activityId"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// shortcutApproveStr coerces a JSON value into a string, tolerating string and -// numeric encodings (taskId / instance id can arrive as either). -func shortcutApproveStr(v any) string { - switch t := v.(type) { - case string: - return t - case float64: - return strconv.FormatFloat(t, 'f', -1, 64) - case int64: - return strconv.FormatInt(t, 10) - } - return "" -} - -func shortcutApproveLabels(items []shortcutApproveInstance) []string { - out := make([]string, 0, len(items)) - for _, c := range items { - out = append(out, fmt.Sprintf("%s(instanceId=%s)", c.title, c.instanceID)) - } - return out -} - -func init() { - shortcut.Register(Approve) -} diff --git a/internal/shortcut/smart/attendance_strict.go b/internal/shortcut/smart/attendance_strict.go new file mode 100644 index 00000000..12ebddf8 --- /dev/null +++ b/internal/shortcut/smart/attendance_strict.go @@ -0,0 +1,62 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" +) + +func attendanceRecordsResult() *contract.ResultSpec { + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + DataSchema: json.RawMessage(`{"type":"object","description":"严格校验的个人考勤打卡流水","properties":{"count":{"type":"integer","description":"有效打卡流水数量"},"records":{"type":"array","description":"个人打卡流水","items":{"type":"object","description":"打卡流水记录","additionalProperties":true}},"complete":{"type":"boolean","description":"当前时间窗内结果是否完整"}},"required":["count","records","complete"],"additionalProperties":false}`), + } +} + +func outputStrictAttendanceRecords(rt *shortcut.RuntimeContext, data map[string]any) error { + records, err := responsecheck.RequireObjectCollection(data, "attendance-wukong/query_check_record", "result") + if err != nil { + return err + } + seen := make(map[int64]struct{}, len(records)) + for index, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + return responsecheck.Error("attendance-wukong/query_check_record", "invalid_item_identity", fmt.Sprintf("第 %d 项缺少大于 0 的稳定打卡 ID", index)) + } + if _, duplicate := seen[identity]; duplicate { + return responsecheck.Error("attendance-wukong/query_check_record", "duplicate_item_identity", fmt.Sprintf("第 %d 项稳定打卡 ID 重复", index)) + } + seen[identity] = struct{}{} + } + return rt.Output(map[string]any{ + "count": len(records), + "records": records, + "complete": true, + }) +} + +func smartAttendancePositiveInteger(value any) (int64, bool) { + switch number := value.(type) { + case int: + return int64(number), number > 0 + case int32: + return int64(number), number > 0 + case int64: + return number, number > 0 + case float64: + return int64(number), number > 0 && number == float64(int64(number)) + case json.Number: + parsed, err := strconv.ParseInt(string(number), 10, 64) + return parsed, err == nil && parsed > 0 + default: + return 0, false + } +} diff --git a/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..cb50b30c --- /dev/null +++ b/internal/shortcut/smart/attendance_strict_cross_platform_coverage_test.go @@ -0,0 +1,245 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/responsecheck" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" + "github.com/spf13/cobra" +) + +func TestCrossPlatformCoverageSmartAttendanceEmptyOnlyLeavesAreUnavailable(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + hasResult bool + availability string + }{ + {MyAttendance.Command, MyAttendance.OutputRollout, MyAttendance.Contract.Result != nil, MyAttendance.Contract.Interface.Availability}, + {ThisMonthAttendance.Command, ThisMonthAttendance.OutputRollout, ThisMonthAttendance.Contract.Result != nil, ThisMonthAttendance.Contract.Interface.Availability}, + } { + if declaration.rollout != output.RolloutLegacyOnly { + t.Errorf("%s rollout = %q, want legacy_only while unavailable", declaration.name, declaration.rollout) + } + if declaration.hasResult || declaration.availability != contract.InterfaceAvailable { + t.Errorf("%s must omit Result and preserve its Schema-compatible non-public Interface", declaration.name) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceProfileFailsClosed(t *testing.T) { + valid := map[string]any{"success": true, "result": []any{map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}} + if got := strictAttendanceCurrentUserID(valid); got != "u1" { + t.Fatalf("valid profile identity = %q", got) + } + invalid := []map[string]any{ + nil, + {"result": map[string]any{"userId": "stale"}}, + {"success": false, "result": map[string]any{"userId": "stale"}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"malformed", map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"userId": "u2"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u2"}}}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +type smartAttendanceProfileCaller struct { + profile string + attendance string + profileErr error + attendanceErr error + calls []string + arguments []map[string]any +} + +func (c *smartAttendanceProfileCaller) CallTool(_ context.Context, server, tool string, arguments map[string]any) (*edition.ToolResult, error) { + c.calls = append(c.calls, server+"/"+tool) + c.arguments = append(c.arguments, arguments) + text := c.profile + callErr := c.profileErr + if len(c.calls) > 1 { + text = c.attendance + callErr = c.attendanceErr + } + if callErr != nil { + return nil, callErr + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} +func (*smartAttendanceProfileCaller) Format() string { return "json" } +func (*smartAttendanceProfileCaller) DryRun() bool { return false } +func (*smartAttendanceProfileCaller) Fields() string { return "" } +func (*smartAttendanceProfileCaller) JQ() string { return "" } + +func TestCrossPlatformCoverageSmartAttendanceInvalidProfileMakesZeroAttendanceCalls(t *testing.T) { + caller := &smartAttendanceProfileCaller{} + helpers.InitDepsForTest(t, caller) + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + caller.calls = nil + caller.arguments = nil + caller.profile = `{"success":false,"result":{"userId":"stale"}}` + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil { + t.Errorf("%s accepted success=false profile", declaration.Command) + } + if len(caller.calls) != 1 || !strings.HasSuffix(caller.calls[0], "/get_current_user_profile") { + t.Errorf("%s calls = %#v, want profile call only", declaration.Command, caller.calls) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceStrictResolverShapes(t *testing.T) { + valid := []map[string]any{ + {"success": true, "result": map[string]any{"userId": " u1 "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": map[string]any{"userId": "u1", "orgEmployeeModel": map[string]any{"userId": "u1"}}}, + {"success": true, "result": []any{map[string]any{"userId": "u1"}, map[string]any{"orgEmployeeModel": map[string]any{"userId": "u1"}}}}, + } + for index, profile := range valid { + if got := strictAttendanceCurrentUserID(profile); got != "u1" { + t.Errorf("valid profile %d resolved %q", index, got) + } + } + invalid := []map[string]any{ + {"success": true, "result": []any{}}, + {"success": true, "result": map[string]any{}}, + {"success": true, "result": map[string]any{"userId": 1}}, + {"success": true, "result": map[string]any{"userId": " "}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": "bad"}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": 1}}}, + {"success": true, "result": map[string]any{"orgEmployeeModel": map[string]any{"userId": " "}}}, + {"success": true, "result": true}, + } + for index, profile := range invalid { + if got := strictAttendanceCurrentUserID(profile); got != "" { + t.Errorf("invalid profile %d resolved %q", index, got) + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordIdentityFailsClosed(t *testing.T) { + for _, data := range []map[string]any{ + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": ""}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } { + if records, err := responsecheck.RequireObjectCollection(data, "attendance/test", "result"); err == nil { + seen := map[int64]struct{}{} + valid := true + for _, record := range records { + identity, ok := smartAttendancePositiveInteger(record["id"]) + if !ok { + valid = false + break + } + if _, duplicate := seen[identity]; duplicate { + valid = false + break + } + seen[identity] = struct{}{} + } + if valid { + t.Fatalf("invalid records accepted: %#v", data) + } + } + } +} + +func TestCrossPlatformCoverageSmartAttendanceRecordOutputContract(t *testing.T) { + result := attendanceRecordsResult() + if result == nil || len(result.Outcomes) != 2 || !json.Valid(result.DataSchema) { + t.Fatalf("invalid strict attendance result declaration: %#v", result) + } + + for _, value := range []any{int(1), int32(2), int64(3), float64(4), json.Number("5")} { + if _, ok := smartAttendancePositiveInteger(value); !ok { + t.Errorf("valid record identity rejected: %#v", value) + } + } + for _, value := range []any{int(0), int32(-1), int64(0), float64(1.5), json.Number("bad"), "1", nil} { + if _, ok := smartAttendancePositiveInteger(value); ok { + t.Errorf("invalid record identity accepted: %#v", value) + } + } + + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: "attendance-test"}, shortcut.Shortcut{}) + invalid := []map[string]any{ + nil, + {"success": false, "result": []any{}}, + {"success": true, "result": nil}, + {"success": true, "result": []any{"bad"}}, + {"success": true, "result": []any{map[string]any{"id": 0}}}, + {"success": true, "result": []any{map[string]any{"id": 1}, map[string]any{"id": 1}}}, + } + for index, data := range invalid { + if err := outputStrictAttendanceRecords(rt, data); err == nil { + t.Errorf("invalid strict output %d accepted", index) + } + } + if err := outputStrictAttendanceRecords(rt, map[string]any{"success": true, "result": []any{map[string]any{"id": int64(1)}}}); err != nil { + t.Fatalf("valid strict output rejected: %v", err) + } +} + +func TestCrossPlatformCoverageSmartAttendanceExecuteSuccessAndFailures(t *testing.T) { + validProfile := `{"success":true,"result":{"userId":"u1"}}` + validAttendance := `{"success":true,"result":[{"id":1}]}` + for _, declaration := range []*shortcut.Shortcut{&MyAttendance, &ThisMonthAttendance} { + t.Run(declaration.Command+" success", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: validAttendance} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 2 || !strings.HasSuffix(caller.calls[1], "/query_check_record") { + t.Fatalf("calls=%#v", caller.calls) + } + request, ok := caller.arguments[1]["QueryCheckRecordRequest"].(map[string]any) + users, usersOK := request["userIds"].([]string) + if !ok || !usersOK || len(users) != 1 || users[0] != "u1" || strings.TrimSpace(fmt.Sprint(request["checkDateFrom"])) == "" || strings.TrimSpace(fmt.Sprint(request["checkDateTo"])) == "" { + t.Fatalf("request=%#v", caller.arguments[1]) + } + }) + t.Run(declaration.Command+" profile call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profileErr: fmt.Errorf("profile unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 1 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" attendance call error", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendanceErr: fmt.Errorf("attendance unavailable")} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + t.Run(declaration.Command+" malformed attendance", func(t *testing.T) { + caller := &smartAttendanceProfileCaller{profile: validProfile, attendance: `{"success":true,"result":null}`} + helpers.InitDepsForTest(t, caller) + rt := shortcut.RuntimeContextForTest(&cobra.Command{Use: declaration.Command}, *declaration) + if err := declaration.Execute(rt); err == nil || len(caller.calls) != 2 { + t.Fatalf("err=%v calls=%#v", err, caller.calls) + } + }) + } +} diff --git a/internal/shortcut/smart/calendar_shared.go b/internal/shortcut/smart/calendar_shared.go index a50709cf..96bee89e 100644 --- a/internal/shortcut/smart/calendar_shared.go +++ b/internal/shortcut/smart/calendar_shared.go @@ -23,6 +23,7 @@ import ( apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/calendarcompat" ) const calendarSmartMaxPages = 20 @@ -215,6 +216,7 @@ func calendarSmartEventPage(data map[string]any) ([]map[string]any, bool, string if !hasMore && next != "" { return nil, false, "", calendarSmartError("calendar/list_calendar_events", "inconsistent_pagination", "服务端返回 hasMore=false 但 nextCursor 非空") } + list, _ = calendarcompat.NormalizeTerminalEmptyEvents(list, result) events := make([]map[string]any, 0, len(list)) for index, item := range list { event, ok := item.(map[string]any) diff --git a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go index a5b989cf..eb65e7da 100644 --- a/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go +++ b/internal/shortcut/smart/calendar_strict_cross_platform_coverage_test.go @@ -148,6 +148,18 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) { if err != nil || items == nil || len(items) != 0 || hasMore || next != "" { t.Fatalf("explicit empty page items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err) } + items, hasMore, next, err = calendarSmartEventPage(map[string]any{ + "success": true, + "result": map[string]any{ + "events": []any{map[string]any{ + "attendees": nil, "categories": nil, "meetingRooms": nil, "reminders": nil, + }}, + "hasMore": false, + }, + }) + if err != nil || items == nil || len(items) != 0 || hasMore || next != "" { + t.Fatalf("service empty sentinel items=%#v hasMore=%v next=%q err=%v", items, hasMore, next, err) + } invalidPages := []map[string]any{ {}, @@ -157,6 +169,7 @@ func TestCrossPlatformCoverageCalendarSmartStrictCollections(t *testing.T) { {"success": true, "result": map[string]any{"events": []any{}, "hasMore": true}}, {"success": true, "result": map[string]any{"events": []any{}, "hasMore": false, "nextCursor": "unexpected"}}, {"success": true, "result": map[string]any{"events": []any{map[string]any{"id": "event-placeholder"}}, "hasMore": false}}, + {"success": true, "result": map[string]any{"events": []any{map[string]any{"summary": nil}}, "hasMore": false}}, {"success": true, "result": map[string]any{"events": []any{"bad-item"}, "hasMore": false}}, } for index, page := range invalidPages { diff --git a/internal/shortcut/smart/done_approvals.go b/internal/shortcut/smart/done_approvals.go deleted file mode 100644 index f75b51fe..00000000 --- a/internal/shortcut/smart/done_approvals.go +++ /dev/null @@ -1,109 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// DoneApprovals: READ-ONLY list of the approval tasks I have already processed -// (my approval history), projected to clean fields. Unlike `oa +approve-by` -// (which agrees to an approval) or `oa +pending` (which lists what still waits -// for me), this shortcut only looks back at what I've already handled — it never -// approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my done approvals via get_done_tasks with pageNumber / pageSize as -// float64, mirroring `dws oa approval list-executed`; optional --limit maps -// to pageSize (float64). This tool takes NO time window (unlike -// list_pending_approvals) — only pageNumber/pageSize/query. -// -// 2. defensively unwrap the returned instance list (reusing shortcutApproveItems -// from approve.go, multiple candidate container keys + one nested level) and -// project each entry to a readable shape {title, originatorName, -// processInstanceId, createTime}, every field probed across candidate keys. -// -// 3. if nothing has been processed, report "没有已处理的审批记录" instead of an -// empty raw dump. -// -// dws oa +done-approvals -// dws oa +done-approvals --limit 10 -var DoneApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+done-approvals", - Product: "oa", - Description: "只读列出我已处理过的审批任务(审批历史)并投影为可读列表", - Intent: "当你只想快速回看「我已经处理过(同意/拒绝)」的审批任务历史——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你的已办审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改任何审批;" + - "若没有任何已处理记录则提示「没有已处理的审批记录」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +done-approvals`, - `dws oa +done-approvals --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my done approvals. pageNumber/pageSize are float64. Keys - // copied verbatim from helpers.list-executed (get_done_tasks). This tool - // carries no time window; pageNumber defaults to the first page. - // pageSize is required by get_done_tasks — the 1:1 list-executed always - // sends one (a missing/zero pageSize triggers a backend business error), - // so default to 20 and let --limit override. - params := map[string]any{ - "pageNumber": float64(1), - "pageSize": float64(20), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "get_done_tasks", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没有已处理的审批记录") - } - - return rt.Output(map[string]any{"count": len(results), "done": results}) - }, -} - -func init() { - shortcut.Register(DoneApprovals) -} diff --git a/internal/shortcut/smart/find_mail_user.go b/internal/shortcut/smart/find_mail_user.go index 98d2be5f..db2fe0d2 100644 --- a/internal/shortcut/smart/find_mail_user.go +++ b/internal/shortcut/smart/find_mail_user.go @@ -15,11 +15,11 @@ package smart import ( "strconv" + "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "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" ) @@ -71,6 +71,13 @@ var FindMailUser = shortcut.Shortcut{ PrimaryCLIPath: "mail +find-mail-user", }, Description: "按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等)", + Parameters: []contract.ParamDecl{ + // Keep the published composite Shortcut property stable. Execute owns + // the explicit query -> keyword adapter for search_mail_users. + {Name: "query", Property: "query"}, + {Name: "limit", Property: "limit"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -87,8 +94,19 @@ var FindMailUser = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填)", Required: true}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选)", Required: false}, + {Name: "query", Type: shortcut.FlagString, Desc: "搜索关键词(姓名/花名/邮箱片段,必填且不能为空)", Required: true}, + {Name: "limit", Type: shortcut.FlagInt, Desc: "返回条数上限(可选,1-100)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +find-mail-user --query "张三"`, @@ -101,69 +119,109 @@ var FindMailUser = shortcut.Shortcut{ if rt.Changed("limit") { args["size"] = strconv.Itoa(rt.Int("limit")) } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } data, err := rt.CallMCPData("mail", "search_mail_users", args) if err != nil { return err } // Step 2 — project matched users. - users := findMailUserUnwrap(data) + users, err := smartMailCollection(data, "mail/search_mail_users", "users") + if err != nil { + return err + } results := make([]map[string]any, 0, len(users)) for _, u := range users { - results = append(results, map[string]any{ - "name": findMailUserString(u, "name", "displayName", "userName", "nick"), - "nickname": findMailUserString(u, "nickname", "nick", "displayName"), - "email": findMailUserString(u, "email", "mail", "emailAddress", "address", "account"), - "employeeNo": findMailUserAny(u, "employeeNo", "employeeNumber", "jobNumber"), - "jobTitle": findMailUserString(u, "jobTitle", "title", "position"), - "workLocation": findMailUserString(u, "workLocation", "location", "workPlace"), - "id": findMailUserAny(u, "id", "userId", "userid"), - }) + projected, err := findMailUserProjection(u) + if err != nil { + return err + } + results = append(results, projected) } - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("没搜到邮箱联系人") + complete, next, err := smartMailPage(data, "mail/search_mail_users", "", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"users": results, "count": len(results)}) + return smartMailOutputPage(rt, "users", results, complete, next) }, } -// findMailUserUnwrap extracts the user list from a search_mail_users response. -// The helper documents the list under "users"; we probe several container keys -// at the top level and one level deep, defensively. -func findMailUserUnwrap(data map[string]any) []map[string]any { - keys := []string{"users", "contacts", "result", "data", "list", "items", "records"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return findMailUserToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return findMailUserToMaps(arr) - } - } - } +func findMailUserProjection(user map[string]any) (map[string]any, error) { + result := map[string]any{ + "name": findMailUserString(user, "name", "displayName", "userName", "nick"), + "nickname": findMailUserString(user, "nickname", "nick", "displayName"), + "employeeNo": findMailUserAny(user, "employeeNo", "employeeNumber", "jobNumber"), + "jobTitle": findMailUserString(user, "jobTitle", "title", "position"), + "workLocation": findMailUserString(user, "workLocation", "location", "workPlace"), } - return nil + email, emailPresent, err := findMailUserIdentityString(user, "email", "mail", "emailAddress", "address", "account") + if err != nil { + return nil, err + } + if emailPresent { + result["email"] = email + } + id, idPresent, err := findMailUserIdentity(user, "id", "userId", "userid") + if err != nil { + return nil, err + } + if idPresent { + result["id"] = id + } + if !emailPresent && !idPresent { + return nil, smartMailError("mail/search_mail_users", "missing_item_identity", "邮箱用户缺少非空 id 或 email") + } + return result, nil } -func findMailUserToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) +func findMailUserIdentityString(m map[string]any, keys ...string) (string, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + text, ok := value.(string) + if !ok { + return "", false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 email 必须是字符串") + } + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil } } - return out + return "", false, nil +} + +func findMailUserIdentity(m map[string]any, keys ...string) (any, bool, error) { + for _, key := range keys { + value, present := m[key] + if !present || value == nil { + continue + } + if text, ok := value.(string); ok { + text = strings.TrimSpace(text) + if text != "" { + return text, true, nil + } + continue + } + switch value.(type) { + case int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return value, true, nil + default: + return nil, false, smartMailError("mail/search_mail_users", "malformed_item_identity", "邮箱用户 id 必须是字符串或数字") + } + } + return nil, false, nil } func findMailUserString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -172,8 +230,11 @@ func findMailUserString(m map[string]any, keys ...string) string { func findMailUserAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -182,5 +243,6 @@ func findMailUserAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&FindMailUser, "users", "严格校验的邮箱用户搜索结果") shortcut.Register(FindMailUser) } diff --git a/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go new file mode 100644 index 00000000..bd62a5f7 --- /dev/null +++ b/internal/shortcut/smart/mail_changed_line_cross_platform_coverage_test.go @@ -0,0 +1,389 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "context" + "fmt" + "io" + "reflect" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailRuntimeForCoverage(t *testing.T, declaration shortcut.Shortcut, values map[string]string) *shortcut.RuntimeContext { + t.Helper() + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + for name, value := range values { + if err := cmd.Flags().Set(name, value); err != nil { + t.Fatalf("set --%s: %v", name, err) + } + } + return shortcut.RuntimeContextForTest(cmd, declaration) +} + +func TestCrossPlatformCoverageSmartMailStrictWireBranches(t *testing.T) { + for _, declaration := range []shortcut.Shortcut{SearchMail, FindMailUser} { + rt := smartMailRuntimeForCoverage(t, declaration, map[string]string{"query": " "}) + if err := declaration.Validate(rt); err == nil { + t.Fatal("direct required query validator accepted whitespace") + } + } + if err := smartMailSuccess(map[string]any{}, "mail/test"); err == nil { + t.Fatal("empty response unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": false}, "mail/test"); err == nil { + t.Fatal("explicit failure unexpectedly succeeded") + } + if err := smartMailSuccess(map[string]any{"success": "true"}, "mail/test"); err != nil { + t.Fatalf("string success rejected: %v", err) + } + if _, ok := smartMailLookup(map[string]any{"result": "bad"}, "result.items"); ok { + t.Fatal("non-object lookup unexpectedly succeeded") + } + if _, err := smartMailRows(map[string]any{}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted invalid response") + } + if _, err := smartMailRows(map[string]any{"success": true, "items": []any{map[string]any{"name": "missing id"}}}, "mail/test", "items", "id"); err == nil { + t.Fatal("rows accepted missing identity") + } + + items, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": 0, "nextCursor": "$"}, "mail/search") + if err != nil || len(items) != 0 { + t.Fatalf("explicit empty search rejected: %v %v", items, err) + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{}, "total": -1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("negative total unexpectedly succeeded") + } + if _, err := smartMailSearchRows(map[string]any{"success": true, "messages": []any{map[string]any{"subject": "missing id"}}, "total": 1, "nextCursor": "$"}, "mail/search"); err == nil { + t.Fatal("search row without id unexpectedly succeeded") + } + if smartMailEmptySearchSentinel(map[string]any{}) { + t.Fatal("empty object treated as reviewed sentinel") + } + if !smartMailEmptySearchSentinel(map[string]any{"tags": nil}) { + t.Fatal("reviewed null-only sentinel rejected") + } + + for _, tc := range []struct { + value any + want int + ok bool + }{ + {value: 7, want: 7, ok: true}, + {value: float64(8), want: 8, ok: true}, + {value: 1.5, ok: false}, + {value: "9", want: 9, ok: true}, + {value: []any{}, ok: false}, + } { + got, ok := smartMailInt(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("smartMailInt(%#v)=(%d,%v), want (%d,%v)", tc.value, got, ok, tc.want, tc.ok) + } + } + + for name, data := range map[string]map[string]any{ + "missing prefix": {}, + "malformed prefix": {"result": []any{}}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "result", ""); err == nil { + t.Fatal("invalid prefixed page unexpectedly succeeded") + } + }) + } + for name, data := range map[string]map[string]any{ + "missing cursor": {}, + "wrong cursor": {"nextCursor": 7}, + "wrong hasMore": {"nextCursor": "cursor-2", "hasMore": []any{}}, + "conflicting true": {"nextCursor": "", "hasMore": true}, + "conflicting false": {"nextCursor": "cursor-2", "hasMore": false}, + } { + t.Run(name, func(t *testing.T) { + if _, _, err := smartMailPage(data, "mail/page", "", ""); err == nil { + t.Fatal("invalid page unexpectedly succeeded") + } + }) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "$"}, "mail/page", "", ""); err != nil || !complete || next != "" { + t.Fatalf("terminal page rejected: %v %q %v", complete, next, err) + } + if complete, next, err := smartMailPage(map[string]any{"nextCursor": "cursor-2"}, "mail/page", "", ""); err != nil || complete || next != "cursor-2" { + t.Fatalf("continuing page rejected: %v %q %v", complete, next, err) + } + if _, _, err := smartMailPage(map[string]any{"nextCursor": "cursor-1"}, "mail/page", "", "cursor-1"); err == nil { + t.Fatal("unstated hasMore repeated cursor unexpectedly succeeded") + } + if value, ok := smartMailBool("true"); !ok || !value { + t.Fatal("string true not normalized") + } + if value, ok := smartMailBool("false"); !ok || value { + t.Fatal("string false not normalized") + } + if _, ok := smartMailBool(7); ok { + t.Fatal("invalid boolean unexpectedly normalized") + } +} + +func TestCrossPlatformCoverageSmartMailSchemaAndOutputBoundaries(t *testing.T) { + assertPanic := func(name string, fn func()) { + t.Helper() + t.Run(name, func(t *testing.T) { + defer func() { + if recover() == nil { + t.Fatal("unsupported schema did not panic") + } + }() + fn() + }) + } + assertPanic("sensitive collection", func() { smartMailSensitivePaths("unknown") }) + assertPanic("identity collection", func() { smartMailIdentitySchema("unknown") }) + + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", nil, true, "unexpected-next"); err == nil { + t.Fatal("invalid terminal pagination unexpectedly stored") + } +} + +func TestCrossPlatformCoverageSmartMailSenderAndProjectionVariants(t *testing.T) { + for _, tc := range []struct { + name string + data map[string]any + want string + }{ + {name: "top string", data: map[string]any{"success": true, "emailAccounts": []any{" mail@example.invalid "}}, want: "mail@example.invalid"}, + {name: "top object", data: map[string]any{"success": true, "emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result string", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "result object", data: map[string]any{"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + {name: "data string", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{"mail@example.invalid"}}}, want: "mail@example.invalid"}, + {name: "data object", data: map[string]any{"success": true, "data": map[string]any{"emailAccounts": []any{map[string]any{"email": "mail@example.invalid"}}}}, want: "mail@example.invalid"}, + } { + t.Run("mailbox "+tc.name, func(t *testing.T) { + got, err := searchMailMailboxAddresses(tc.data) + if err != nil || len(got) != 1 || got[0] != tc.want { + t.Fatalf("addresses=%#v want=%q err=%v", got, tc.want, err) + } + }) + } + for name, data := range map[string]map[string]any{ + "remote failure": {"success": false}, + "missing": {"success": true}, + "malformed object": {"success": true, "result": map[string]any{"emailAccounts": []any{map[string]any{"email": 7}}}}, + "bad later item": {"success": true, "emailAccounts": []any{"mail@example.invalid", 7}}, + "multiple paths": {"success": true, "emailAccounts": []any{"one@example.invalid"}, "data": map[string]any{"emailAccounts": []any{"two@example.invalid"}}}, + } { + t.Run("mailbox "+name, func(t *testing.T) { + if _, err := searchMailMailboxAddresses(data); err == nil { + t.Fatal("invalid mailbox response unexpectedly succeeded") + } + }) + } + + for _, tc := range []struct { + message map[string]any + want any + }{ + {message: map[string]any{"from": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender", "email": "sender@example.invalid"}}, want: "Sender "}, + {message: map[string]any{"from": map[string]any{"email": "sender@example.invalid"}}, want: "sender@example.invalid"}, + {message: map[string]any{"from": map[string]any{"name": "Sender"}}, want: "Sender"}, + {message: map[string]any{"sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + {message: map[string]any{"from": " ", "sender": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + got, err := searchMailFrom(tc.message) + if err != nil || got != tc.want { + t.Fatalf("sender=%#v want=%#v err=%v", got, tc.want, err) + } + } + for _, message := range []map[string]any{{}, {"from": " "}, {"from": nil}, {"from": map[string]any{}}, {"from": 7}} { + if _, err := searchMailFrom(message); err == nil { + t.Fatal("malformed sender unexpectedly succeeded") + } + } + if got := searchMailFirstString(map[string]any{"id": " stable-id "}, "id"); got != "stable-id" { + t.Fatalf("string normalization=%q", got) + } + if got := searchMailFirstAny(map[string]any{"first": " ", "second": " value "}, "first", "second"); got != "value" { + t.Fatalf("any string normalization=%#v", got) + } + if got := searchMailFirstAny(map[string]any{"value": 7}, "value"); got != 7 { + t.Fatalf("numeric projection=%#v", got) + } + + projected, err := findMailUserProjection(map[string]any{ + "id": " user-1 ", "email": " user@example.invalid ", "name": " User ", "nickname": " Nick ", + "employeeNo": " E1 ", "jobTitle": " Engineer ", "workLocation": " HQ ", + }) + if err != nil || projected["id"] != "user-1" || projected["email"] != "user@example.invalid" || projected["name"] != "User" || projected["employeeNo"] != "E1" { + t.Fatalf("normalized user projection=%#v err=%v", projected, err) + } + if got := findMailUserAny(map[string]any{"first": " ", "second": 7}, "first", "second"); got != 7 { + t.Fatalf("find user any=%#v", got) + } + if got := findMailUserAny(map[string]any{}, "missing"); got != nil { + t.Fatalf("missing optional value=%#v", got) + } +} + +func TestCrossPlatformCoverageSmartMailLeafFailureBoundaries(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + errors map[string]error + wantCalls int + }{ + {name: "search mailbox malformed", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "search mailbox blank string", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[" "]}`}, wantCalls: 1}, + {name: "search mailbox malformed item", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[7]}`}, wantCalls: 1}, + {name: "search mailbox empty", declaration: SearchMail, args: []string{"--query", "fixture"}, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":[]}`}, wantCalls: 1}, + {name: "search rows malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "search sender malformed", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "search page missing", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "find rows malformed", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":{}}`}, wantCalls: 1}, + {name: "find page missing", declaration: FindMailUser, args: []string{"--query", "fixture"}, responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1"}]}`}, wantCalls: 1}, + {name: "triage inbox folders malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"list_folders": `{"success":true,"folders":{}}`}, wantCalls: 1}, + {name: "triage rows malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "triage sender malformed", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "triage page missing", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + {name: "triage remote failure", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, responses: map[string]string{}, errors: map[string]error{"search_emails": fmt.Errorf("search transport")}, wantCalls: 1}, + {name: "triage mailbox failure", declaration: TriageMail, responses: map[string]string{"list_user_mailboxes": `{"success":true,"emailAccounts":{}}`}, wantCalls: 1}, + {name: "recent rows malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":{}}}`}, wantCalls: 1}, + {name: "recent sender malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{}]}]}}`}, wantCalls: 1}, + {name: "recent page malformed", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1"}, responses: map[string]string{"list_mailbox_threads": `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"nextCursor":7}}`}, wantCalls: 1}, + {name: "unread rows malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":{}}`}, wantCalls: 1}, + {name: "unread sender malformed", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":[]}],"hasMore":false,"nextCursor":""}`}, wantCalls: 1}, + {name: "unread page missing", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, responses: map[string]string{"search_emails": `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}]}`}, wantCalls: 1}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses, errors: tc.errors} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid leaf response unexpectedly succeeded") + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailInboxAndSenderFallbacks(t *testing.T) { + for _, tc := range []struct { + name string + message map[string]any + want any + wantErr bool + }{ + {name: "name and address", message: map[string]any{"senders": []any{map[string]any{"name": "Sender", "email": "sender@example.invalid"}}}, want: []string{"Sender "}}, + {name: "address only", message: map[string]any{"senders": []any{map[string]any{"email": "sender@example.invalid"}}}, want: []string{"sender@example.invalid"}}, + {name: "name only", message: map[string]any{"senders": []any{map[string]any{"name": "Sender"}}}, want: []string{"Sender"}}, + {name: "wrong sender collection", message: map[string]any{"senders": "bad"}, wantErr: true}, + {name: "fallback sender", message: map[string]any{"senders": []any{}, "from": "sender@example.invalid"}, want: "sender@example.invalid"}, + } { + t.Run(tc.name, func(t *testing.T) { + got, err := recentMailSenders(tc.message) + if tc.wantErr && err == nil { + t.Fatal("malformed senders unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid senders failed: %v", err) + } + if !tc.wantErr && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("senders=%#v want=%#v", got, tc.want) + } + }) + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + responses map[string]string + wantErr bool + wantCalls int + }{ + { + name: "triage resolves mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":[{"email":"mail@example.invalid"}]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "search resolves wrapped string mailbox", declaration: SearchMail, args: []string{"--query", "fixture"}, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"result":{"emailAccounts":["mail@example.invalid"]}}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 2, + }, + { + name: "triage resolves string mailbox and inbox", declaration: TriageMail, + responses: map[string]string{ + "list_user_mailboxes": `{"success":true,"emailAccounts":["mail@example.invalid"]}`, + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "search_emails": `{"success":true,"messages":[],"total":0,"hasMore":false,"nextCursor":""}`, + }, wantCalls: 3, + }, + { + name: "recent resolves inbox", declaration: RecentMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{ + "list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Inbox"}]}`, + "list_mailbox_threads": `{"success":true,"result":{"conversations":[],"hasMore":false,"nextCursor":""}}`, + }, wantCalls: 2, + }, + { + name: "inbox not found", declaration: TriageMail, args: []string{"--email", "mail@example.invalid"}, + responses: map[string]string{"list_folders": `{"success":true,"folders":[{"id":"folder-1","displayName":"Archive"}]}`}, + wantErr: true, wantCalls: 1, + }, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: tc.responses} + err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...) + if tc.wantErr && err == nil { + t.Fatal("invalid inbox resolution unexpectedly succeeded") + } + if !tc.wantErr && err != nil { + t.Fatalf("valid inbox resolution failed: %v", err) + } + if len(caller.calls) != tc.wantCalls { + t.Fatalf("calls=%d want=%d", len(caller.calls), tc.wantCalls) + } + }) + } + + for _, tc := range []struct { + name string + response string + reason string + }{ + {name: "blank sender", response: `{"success":true,"messages":[{"id":"message-1","from":" "}],"hasMore":false,"nextCursor":""}`, reason: "malformed_sender"}, + {name: "missing sender", response: `{"success":true,"messages":[{"id":"message-1"}],"hasMore":false,"nextCursor":""}`, reason: "missing_sender"}, + } { + t.Run(tc.name+" typed reason", func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": tc.response}} + err := runSmartMailDeclaration(t, SearchMail, caller, "--email", "mail@example.invalid", "--query", "fixture") + typed, ok := err.(*apperrors.Error) + if err == nil || !ok || typed.Reason != tc.reason || len(caller.calls) != 1 { + reason := "" + if ok { + reason = typed.Reason + } + t.Fatalf("err=%v reason=%q calls=%d", err, reason, len(caller.calls)) + } + }) + } +} diff --git a/internal/shortcut/smart/mail_space_todo_projection_test.go b/internal/shortcut/smart/mail_space_todo_projection_test.go index ba41a90c..fd18632e 100644 --- a/internal/shortcut/smart/mail_space_todo_projection_test.go +++ b/internal/shortcut/smart/mail_space_todo_projection_test.go @@ -31,34 +31,6 @@ import ( var errStubTool = errors.New("stub tool error") -// TestSearchMailPickMailbox covers every list_user_mailboxes shape: objects with -// an email field and bare string arrays, each at the top level or one level -// under a result/data wrapper. The nested bare-string case regressed silently -// (searchMailToMaps drops strings), making all smart mail shortcuts wrongly -// demand an explicit --email. -func TestSearchMailPickMailbox(t *testing.T) { - cases := []struct { - name, raw, want string - }{ - {"object top-level", `{"emailAccounts":[{"email":"a@x.com"}]}`, "a@x.com"}, - {"object under result", `{"result":{"emailAccounts":[{"email":"b@x.com"}]}}`, "b@x.com"}, - {"bare string top-level", `{"emailAccounts":["c@x.com"]}`, "c@x.com"}, - {"bare string under result", `{"result":{"emailAccounts":["d@x.com"]}}`, "d@x.com"}, - {"empty", `{"result":{"emailAccounts":[]}}`, ""}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - var data map[string]any - if err := json.Unmarshal([]byte(tc.raw), &data); err != nil { - t.Fatal(err) - } - if got := searchMailPickMailbox(data); got != tc.want { - t.Fatalf("searchMailPickMailbox(%s) = %q, want %q", tc.raw, got, tc.want) - } - }) - } -} - // TestResolveSpaceItemsWikiSpaces guards that search_wikiSpaces' result.wikiSpaces // container is probed — otherwise +resolve-space silently finds no candidates. func TestResolveSpaceItemsWikiSpaces(t *testing.T) { @@ -142,7 +114,7 @@ func runShortcutErr(t *testing.T, fake *stubMailboxCaller, argv ...string) error // "provide --email" validation error rather than silently proceeding. func TestSmartSearchMailNoMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"result":{"emailAccounts":[]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[]}`, }} err := runShortcutErr(t, fake, "mail", "+search-mail", "--query", "x", "--format", "json") if err == nil || !strings.Contains(err.Error(), "未找到可用邮箱") { @@ -200,8 +172,8 @@ func TestCreatedTodosProjectsCards(t *testing.T) { // the no-mailbox validation error because emailAccounts was not probed. func TestSmartSearchMailAutoResolvesMailbox(t *testing.T) { fake := &stubMailboxCaller{byTool: map[string]string{ - "list_user_mailboxes": `{"emailAccounts":[{"email":"me@example.com"}]}`, - "search_emails": `{"result":{"messages":[{"subject":"weekly report","from":"a@x.com"}]}}`, + "list_user_mailboxes": `{"success":"true","emailAccounts":[{"email":"me@example.com"}]}`, + "search_emails": `{"success":"true","messages":[{"id":"message-1","subject":"weekly report","from":"a@x.com"}],"nextCursor":""}`, }} out := runShortcut(t, fake, "mail", "+search-mail", "--query", "subject:weekly report", "--format", "json") if strings.Contains(out, "未找到可用邮箱") || out == "" { diff --git a/internal/shortcut/smart/mail_strict.go b/internal/shortcut/smart/mail_strict.go new file mode 100644 index 00000000..0240bbff --- /dev/null +++ b/internal/shortcut/smart/mail_strict.go @@ -0,0 +1,340 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + "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/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +func smartMailError(operation, reason, message string) error { + return apperrors.NewAPI(message, + apperrors.WithOperation(operation), + apperrors.WithOrigin("mcp"), + apperrors.WithFailureStage("response_validation"), + apperrors.WithRetryable(false), + apperrors.WithReason(reason), + ) +} + +func smartMailValidatePageSize(rt *shortcut.RuntimeContext, flag string, required bool) error { + if !required && !rt.Changed(flag) { + return nil + } + value := rt.Int(flag) + if value < 1 || value > 100 { + return apperrors.NewValidation(fmt.Sprintf("--%s 必须在 1-100 之间", flag)) + } + return nil +} + +func smartMailStringPageSize(rt *shortcut.RuntimeContext, flag, fallback string) (string, error) { + if !rt.Changed(flag) { + return fallback, nil + } + value, err := strconv.Atoi(rt.Str(flag)) + if err != nil || value < 1 || value > 100 { + return "", apperrors.NewValidation(fmt.Sprintf("--%s 必须是 1-100 之间的整数", flag)) + } + return strconv.Itoa(value), nil +} + +func smartMailValidateStringPageSize(rt *shortcut.RuntimeContext, flag string) error { + _, err := smartMailStringPageSize(rt, flag, "") + return err +} + +func smartMailValidateRequiredText(rt *shortcut.RuntimeContext, flag string) error { + if strings.TrimSpace(rt.Str(flag)) == "" { + return apperrors.NewValidation(fmt.Sprintf("--%s 不能为空", flag)) + } + return nil +} + +func smartMailSuccess(data map[string]any, operation string) error { + if len(data) == 0 { + return smartMailError(operation, "empty_tool_response", "服务返回空响应,无法证明结果确实为空") + } + switch value := data["success"].(type) { + case bool: + if value { + return nil + } + case string: + if value == "true" { + return nil + } + } + if _, present := data["success"]; !present { + return smartMailError(operation, "missing_success", "响应缺少 success 业务状态") + } + return smartMailError(operation, "remote_failure", "服务未明确返回成功状态") +} + +func smartMailLookup(data map[string]any, path string) (any, bool) { + var current any = data + for _, segment := range strings.Split(path, ".") { + object, ok := current.(map[string]any) + if !ok { + return nil, false + } + current, ok = object[segment] + if !ok { + return nil, false + } + } + return current, true +} + +func smartMailCollection(data map[string]any, operation, path string) ([]map[string]any, error) { + if err := smartMailSuccess(data, operation); err != nil { + return nil, err + } + value, present := smartMailLookup(data, path) + if !present { + return nil, smartMailError(operation, "missing_collection", fmt.Sprintf("成功响应缺少 %s 数组;不能把未知响应结构当作空结果", path)) + } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", path, value)) + } + items := make([]map[string]any, 0, len(raw)) + for index, item := range raw { + object, ok := item.(map[string]any) + if !ok || len(object) == 0 { + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 不是非空对象", path, index)) + } + items = append(items, object) + } + return items, nil +} + +func smartMailRows(data map[string]any, operation, path string, identityKeys ...string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, path) + if err != nil { + return nil, err + } + for index, item := range items { + if searchMailFirstString(item, identityKeys...) == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +// smartMailSearchRows handles the one reviewed Mail-specific zero-hit wire +// shape observed from search_emails: total="0", terminal nextCursor="$", and +// exactly one placeholder whose only fields are null recipient metadata. It is +// deliberately narrower than a general "drop bad item" rule. +func smartMailSearchRows(data map[string]any, operation string) ([]map[string]any, error) { + items, err := smartMailCollection(data, operation, "messages") + if err != nil { + return nil, err + } + total, totalOK := smartMailInt(data["total"]) + next, nextOK := data["nextCursor"].(string) + if totalOK && total == 0 && nextOK && (next == "" || next == "$") { + if len(items) == 0 { + return items, nil + } + if len(items) == 1 && smartMailEmptySearchSentinel(items[0]) { + return []map[string]any{}, nil + } + return nil, smartMailError(operation, "conflicting_empty_result", "total=0 的搜索响应包含真实或未审核的消息对象") + } + if totalOK && total < 0 { + return nil, smartMailError(operation, "invalid_total", "邮件搜索 total 不能为负数") + } + for index, item := range items { + if searchMailFirstString(item, "id") == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("结果第 %d 项缺少稳定 ID", index)) + } + } + return items, nil +} + +func smartMailEmptySearchSentinel(item map[string]any) bool { + if len(item) == 0 { + return false + } + allowed := map[string]bool{"toRecipients": true, "ccRecipients": true, "bccRecipients": true, "replyTo": true, "tags": true} + for key, value := range item { + if !allowed[key] || value != nil { + return false + } + } + return true +} + +func smartMailInt(value any) (int, bool) { + switch typed := value.(type) { + case int: + return typed, true + case float64: + if typed != float64(int(typed)) { + return 0, false + } + return int(typed), true + case string: + parsed, err := strconv.Atoi(typed) + return parsed, err == nil + default: + return 0, false + } +} + +func smartMailPage(data map[string]any, operation, prefix, currentCursor string) (bool, string, error) { + container := data + if prefix != "" { + value, present := smartMailLookup(data, prefix) + if !present { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少分页对象") + } + var ok bool + container, ok = value.(map[string]any) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "分页对象类型错误") + } + } + raw, present := container["nextCursor"] + next, ok := raw.(string) + if !present || !ok { + return false, "", smartMailError(operation, "missing_pagination", "响应缺少字符串 nextCursor") + } + if rawMore, present := container["hasMore"]; present { + hasMore, ok := smartMailBool(rawMore) + if !ok { + return false, "", smartMailError(operation, "malformed_pagination", "hasMore 应为布尔值") + } + terminal := strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" + if hasMore == terminal { + return false, "", smartMailError(operation, "conflicting_pagination", "hasMore 与 nextCursor 互相矛盾") + } + if !hasMore { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil + } + if strings.TrimSpace(next) == "" || strings.TrimSpace(next) == "$" { + return true, "", nil + } + if strings.TrimSpace(currentCursor) != "" && strings.TrimSpace(next) == strings.TrimSpace(currentCursor) { + return false, "", smartMailError(operation, "stalled_pagination", "服务端返回了与当前请求相同的 nextCursor") + } + return false, next, nil +} + +func smartMailBool(value any) (bool, bool) { + switch typed := value.(type) { + case bool: + return typed, true + case string: + if typed == "true" { + return true, true + } + if typed == "false" { + return false, true + } + } + return false, false +} + +func smartMailResult(collection, description string) *contract.ResultSpec { + identity := smartMailIdentitySchema(collection) + return &contract.ResultSpec{ + Outcomes: []contract.ResultOutcome{contract.ResultOutcomeSuccess, contract.ResultOutcomeFailure}, + SensitivePaths: smartMailSensitivePaths(collection), + DataSchema: json.RawMessage(fmt.Sprintf(`{"type":"object","description":%q,"properties":{"count":{"type":"integer","minimum":0,"description":"当前响应中的有效记录数量"},%q:{"type":"array","description":%q,"items":{"type":"object","description":"严格校验后的邮件记录",%s,"additionalProperties":true}}},"required":["count",%q],"additionalProperties":false}`, + description, collection, description, identity, collection)), + } +} + +func smartMailSensitivePaths(collection string) []string { + switch collection { + case "messages": + return []string{"messages.subject", "messages.from", "messages.date"} + case "mails": + return []string{"mails.subject", "mails.from", "mails.date"} + case "users": + return []string{"users.name", "users.nickname", "users.email", "users.employeeNo", "users.jobTitle", "users.workLocation"} + default: + panic("unsupported smart Mail sensitive paths: " + collection) + } +} + +func smartMailIdentitySchema(collection string) string { + switch collection { + case "messages": + return `"properties":{"messageId":{"type":"string","minLength":1,"description":"稳定邮件 ID"}},"required":["messageId"]` + case "mails": + return `"properties":{"threadId":{"type":"string","minLength":1,"description":"稳定邮件会话 ID"}},"required":["threadId"]` + case "users": + return `"properties":{"id":{"type":["string","number"],"description":"稳定邮箱用户 ID"},"email":{"type":"string","minLength":1,"description":"稳定邮箱地址身份"}},"anyOf":[{"required":["id"]},{"required":["email"]}]` + default: + panic("unsupported smart Mail Result identity: " + collection) + } +} + +func smartMailCursorPagination() *contract.PaginationSpec { + return &contract.PaginationSpec{ + Kind: contract.PaginationKindCursor, + CursorParameter: "cursor", + MetaPath: contract.PaginationMetaPath, + EndpointExhaustedPath: contract.PaginationExhaustedPath, + NextTokenPath: contract.PaginationNextTokenPath, + } +} + +func hardenSmartMail(declaration *shortcut.Shortcut, collection, description string) { + declaration.OutputRollout = output.RolloutUnifiedActive + declaration.Contract.Result = smartMailResult(collection, description) + declaration.Contract.Pagination = smartMailCursorPagination() + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, + Reason: "Reviewed Mail Shortcut composite: strict success, collection, stable-ID and pagination validation are owned by the executable CLI.", + } +} + +func markSmartMailCompatibilityOnly(declaration *shortcut.Shortcut) { + declaration.OutputRollout = output.RolloutLegacyOnly + declaration.Contract.Result = nil + declaration.Contract.Pagination = nil + declaration.Contract.Interface = &contract.InterfaceSpec{ + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until its live-data proof gap is closed.", + } +} + +func smartMailPayload(collection string, items []map[string]any, complete bool, next string) map[string]any { + payload := map[string]any{"count": len(items), collection: items, "complete": complete} + if !complete { + payload["nextCursor"] = next + } + return payload +} + +func smartMailOutputPage(rt *shortcut.RuntimeContext, collection string, items []map[string]any, complete bool, next string) error { + payload := smartMailPayload(collection, items, complete, next) + if !output.UsesUnifiedResult(rt.Command()) { + return rt.Output(payload) + } + pagination, err := output.NewPagination(complete, next) + if err != nil { + return smartMailError("mail/pagination", "invalid_pagination", err.Error()) + } + business := map[string]any{"count": len(items), collection: items} + meta := &output.Meta{Pagination: pagination, Count: output.NewCount(len(items))} + return output.StoreResult(rt.Command().Context(), output.Success(business, output.WithMeta(meta))) +} diff --git a/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go new file mode 100644 index 00000000..14022542 --- /dev/null +++ b/internal/shortcut/smart/mail_strict_cross_platform_coverage_test.go @@ -0,0 +1,405 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "maps" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition" +) + +type smartMailCursorCall struct { + tool string + args map[string]any +} + +type smartMailCursorCaller struct { + responses map[string]string + errors map[string]error + calls []smartMailCursorCall +} + +func (caller *smartMailCursorCaller) CallTool(_ context.Context, _ string, tool string, args map[string]any) (*edition.ToolResult, error) { + caller.calls = append(caller.calls, smartMailCursorCall{tool: tool, args: maps.Clone(args)}) + if err := caller.errors[tool]; err != nil { + return nil, err + } + text, ok := caller.responses[tool] + if !ok { + return nil, fmt.Errorf("unexpected smart Mail tool: %s", tool) + } + return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil +} + +func (*smartMailCursorCaller) Format() string { return "json" } +func (*smartMailCursorCaller) DryRun() bool { return false } +func (*smartMailCursorCaller) Fields() string { return "" } +func (*smartMailCursorCaller) JQ() string { return "" } + +func runSmartMailDeclaration(t *testing.T, declaration shortcut.Shortcut, caller *smartMailCursorCaller, args ...string) error { + t.Helper() + helpers.InitDepsForTest(t, caller) + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + cmd.SetOut(io.Discard) + cmd.SetErr(io.Discard) + cmd.SetArgs(args) + return cmd.Execute() +} + +func TestCrossPlatformCoverageSmartMailContractsAreUnifiedAndTyped(t *testing.T) { + for _, declaration := range []struct { + name string + rollout output.RolloutState + result bool + }{ + {"search", SearchMail.OutputRollout, SearchMail.Contract.Result != nil}, + {"find-user", FindMailUser.OutputRollout, FindMailUser.Contract.Result != nil}, + {"triage", TriageMail.OutputRollout, TriageMail.Contract.Result != nil}, + } { + if declaration.rollout != output.RolloutUnifiedActive || !declaration.result { + t.Fatalf("%s is not unified with Result", declaration.name) + } + } + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &FindMailUser, &TriageMail} { + if declaration.Contract.Pagination == nil || declaration.Contract.Pagination.CursorParameter != "cursor" { + t.Fatalf("%s missing cursor Pagination", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCompatibilityReadsMatchRuntimeBoundary(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&UnreadMail, &RecentMail} { + if declaration.OutputRollout != output.RolloutLegacyOnly || declaration.Contract.Result != nil || declaration.Contract.Pagination != nil { + t.Errorf("%s unavailable runtime still publishes unified result/pagination", declaration.Command) + } + if declaration.Contract.Interface == nil || declaration.Contract.Interface.Availability != contract.InterfaceAvailable || declaration.Contract.Interface.Reason == "" { + t.Errorf("%s missing Schema-compatible non-public interface", declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailHistoricalStringSizeAndRuntimeDefault(t *testing.T) { + for _, declaration := range []*shortcut.Shortcut{&SearchMail, &UnreadMail} { + found := false + for _, flag := range declaration.Flags { + if flag.Name != "size" { + continue + } + found = true + if flag.Type != shortcut.FlagString || flag.Default != "" { + t.Fatalf("%s --size type/default=%q/%q, want string/empty", declaration.Command, flag.Type, flag.Default) + } + } + if !found { + t.Fatalf("%s missing --size", declaration.Command) + } + cmd := corecmd.New(shortcut.FromShortcut(*declaration)) + if flag := cmd.Flags().Lookup("size"); flag == nil || flag.Value.Type() != "string" || flag.DefValue != "" { + t.Fatalf("%s Cobra --size=%#v, want historical string with empty default", declaration.Command, flag) + } + } + + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + wantSize string + }{ + {name: "search default", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, wantSize: "20"}, + {name: "search explicit", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "100"}, wantSize: "100"}, + {name: "unread default", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid"}, wantSize: "20"}, + {name: "unread explicit", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "1"}, wantSize: "1"}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{"search_emails": `{"success":true,"messages":[],"hasMore":false,"nextCursor":""}`}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatalf("compatible execution failed: %v", err) + } + if len(caller.calls) != 1 || caller.calls[0].args["size"] != tc.wantSize { + t.Fatalf("calls=%#v, want one call with size=%q", caller.calls, tc.wantSize) + } + }) + } + + for _, declaration := range []shortcut.Shortcut{SearchMail, UnreadMail} { + caller := &smartMailCursorCaller{responses: map[string]string{}} + helpers.InitDepsForTest(t, caller) + values := map[string]string{"email": "mail@example.invalid", "size": "bad"} + if declaration.Command == "+search-mail" { + values["query"] = "fixture" + } + rt := smartMailRuntimeForCoverage(t, declaration, values) + if err := declaration.Execute(rt); err == nil { + t.Fatalf("%s direct Execute accepted malformed string size", declaration.Command) + } + if len(caller.calls) != 0 { + t.Fatalf("%s direct Execute reached %d remote calls", declaration.Command, len(caller.calls)) + } + } +} + +func TestCrossPlatformCoverageSmartMailZeroHitSentinelIsNarrow(t *testing.T) { + fixture := map[string]any{ + "success": "true", "total": "0", "nextCursor": "$", + "messages": []any{map[string]any{"ccRecipients": nil, "toRecipients": nil}}, + } + items, err := smartMailSearchRows(fixture, "mail/search_emails") + if err != nil || len(items) != 0 { + t.Fatalf("reviewed zero-hit sentinel must normalize to empty: items=%v err=%v", items, err) + } + for name, bad := range map[string]map[string]any{ + "unexpected field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"subject": nil}}}, + "non-null field": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": "x"}}}, + "multiple rows": {"success": "true", "total": "0", "nextCursor": "$", "messages": []any{map[string]any{"ccRecipients": nil}, map[string]any{"toRecipients": nil}}}, + } { + if _, err := smartMailSearchRows(bad, "mail/search_emails"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailMalformedCollectionsFail(t *testing.T) { + for name, fixture := range map[string]map[string]any{ + "missing success": {"messages": []any{}}, + "missing collection": {"success": "true", "total": "0", "nextCursor": "$"}, + "wrong type": {"success": "true", "messages": map[string]any{}}, + "bad item": {"success": "true", "messages": []any{"bad"}}, + } { + if _, err := smartMailCollection(fixture, "mail/search_emails", "messages"); err == nil { + t.Fatalf("%s must fail closed", name) + } + } +} + +func TestCrossPlatformCoverageSmartMailNestedSendersFailClosed(t *testing.T) { + if _, err := searchMailFrom(map[string]any{"from": []any{"bad"}}); err == nil { + t.Fatal("malformed message from field must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{"bad"}}); err == nil { + t.Fatal("non-object conversation sender must fail") + } + if _, err := recentMailSenders(map[string]any{"senders": []any{map[string]any{"name": ""}}}); err == nil { + t.Fatal("sender without identity must fail") + } +} + +func TestCrossPlatformCoverageSmartMailPaginationOutputAndIdentitySchema(t *testing.T) { + declaration := SearchMail + cmd := corecmd.New(shortcut.FromShortcut(declaration)) + ctx, _ := output.WithResultStore(context.Background()) + cmd.SetContext(ctx) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + rt := shortcut.RuntimeContextForTest(cmd, declaration) + if err := smartMailOutputPage(rt, "messages", []map[string]any{{"messageId": "message-1"}}, false, "cursor-2"); err != nil { + t.Fatal(err) + } + if code, emitted, err := output.EmitStoredResult(cmd); err != nil || !emitted || code != 0 { + t.Fatalf("emit code=%d emitted=%v err=%v", code, emitted, err) + } + var envelope struct { + Data map[string]any `json:"data"` + Meta struct { + Pagination *struct { + EndpointExhausted bool `json:"endpoint_exhausted"` + NextToken string `json:"next_token"` + } `json:"pagination"` + } `json:"meta"` + } + if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil { + t.Fatal(err) + } + for _, field := range []string{"complete", "hasMore", "nextCursor"} { + if _, exists := envelope.Data[field]; exists { + t.Fatalf("pagination field %s leaked into business data", field) + } + } + if envelope.Meta.Pagination == nil || envelope.Meta.Pagination.EndpointExhausted || envelope.Meta.Pagination.NextToken != "cursor-2" { + t.Fatalf("pagination=%+v", envelope.Meta.Pagination) + } + + for _, tc := range []struct { + declaration *shortcut.Shortcut + collection string + identity string + }{ + {&SearchMail, "messages", "messageId"}, + {&TriageMail, "messages", "messageId"}, + {&FindMailUser, "users", "email"}, + } { + if len(tc.declaration.Contract.Result.SensitivePaths) == 0 { + t.Fatalf("%s missing sensitive_paths", tc.declaration.Command) + } + var schema map[string]any + if err := json.Unmarshal(tc.declaration.Contract.Result.DataSchema, &schema); err != nil { + t.Fatalf("%s schema: %v", tc.declaration.Command, err) + } + properties := schema["properties"].(map[string]any) + if minimum := properties["count"].(map[string]any)["minimum"]; minimum != float64(0) { + t.Fatalf("%s count minimum=%v, want 0", tc.declaration.Command, minimum) + } + items := properties[tc.collection].(map[string]any)["items"].(map[string]any) + identity := items["properties"].(map[string]any)[tc.identity].(map[string]any) + if identity["description"] == "" { + t.Fatalf("%s missing stable identity", tc.declaration.Command) + } + } +} + +func TestCrossPlatformCoverageSmartMailCursorPassthroughAndStall(t *testing.T) { + tests := []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "unread", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "triage", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":"message-1","from":"sender@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "find user", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "20", "--cursor", "cursor-1"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-2"}`}, + {name: "recent", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "20", "--cursor", "cursor-1"}, tool: "list_mailbox_threads", response: `{"success":true,"result":{"conversations":[{"id":"thread-1","senders":[{"email":"sender@example.invalid"}]}],"hasMore":true,"nextCursor":"cursor-2"}}`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err != nil { + t.Fatal(err) + } + if len(caller.calls) != 1 || caller.calls[0].args["cursor"] != "cursor-1" { + t.Fatalf("cursor not passed through: %#v", caller.calls) + } + }) + } + caller := &smartMailCursorCaller{responses: map[string]string{"search_mail_users": `{"success":true,"users":[{"id":"user-1","email":"user@example.invalid"}],"hasMore":true,"nextCursor":"cursor-1"}`}} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture", "--cursor", "cursor-1"); err == nil { + t.Fatal("repeated cursor unexpectedly succeeded") + } +} + +func TestCrossPlatformCoverageSmartMailLimitValidationHasZeroCalls(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + }{ + {name: "search blank", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", " "}}, + {name: "search nonnumeric", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "bad"}}, + {name: "search zero", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "0"}}, + {name: "search negative", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "-1"}}, + {name: "search high", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--size", "101"}}, + {name: "unread blank", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", " "}}, + {name: "unread nonnumeric", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "bad"}}, + {name: "unread zero", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "0"}}, + {name: "unread negative", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "-1"}}, + {name: "unread high", declaration: UnreadMail, args: []string{"--email", "mail@example.invalid", "--size", "101"}}, + {name: "triage zero", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture", "--limit", "0"}}, + {name: "find high", declaration: FindMailUser, args: []string{"--query", "fixture", "--limit", "101"}}, + {name: "recent negative", declaration: RecentMail, args: []string{"--email", "mail@example.invalid", "--folder", "folder-1", "--limit", "-1"}}, + {name: "search blank query", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", " "}}, + {name: "find blank query", declaration: FindMailUser, args: []string{"--query", " "}}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("invalid limit unexpectedly succeeded") + } + if len(caller.calls) != 0 { + t.Fatalf("invalid limit reached remote: %d", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserProjectionMatchesResultIdentity(t *testing.T) { + for _, tc := range []struct { + name string + user map[string]any + wantKey string + absentKey string + }{ + {name: "id only", user: map[string]any{"id": "user-1", "email": ""}, wantKey: "id", absentKey: "email"}, + {name: "email only", user: map[string]any{"id": "", "email": "user@example.invalid"}, wantKey: "email", absentKey: "id"}, + {name: "numeric id only", user: map[string]any{"id": float64(7), "email": " "}, wantKey: "id", absentKey: "email"}, + } { + t.Run(tc.name, func(t *testing.T) { + projected, err := findMailUserProjection(tc.user) + if err != nil { + t.Fatal(err) + } + if _, ok := projected[tc.wantKey]; !ok { + t.Fatalf("projection missing %s: %#v", tc.wantKey, projected) + } + if _, ok := projected[tc.absentKey]; ok { + t.Fatalf("projection retained empty %s: %#v", tc.absentKey, projected) + } + }) + } + + caller := &smartMailCursorCaller{responses: map[string]string{ + "search_mail_users": `{"success":true,"users":[{"id":"","email":""}],"hasMore":false,"nextCursor":""}`, + }} + if err := runSmartMailDeclaration(t, FindMailUser, caller, "--query", "fixture"); err == nil { + t.Fatal("identity-free user unexpectedly satisfied runtime Result contract") + } + for name, user := range map[string]map[string]any{ + "malformed email with valid id": {"id": "user-1", "email": 7}, + "malformed id with valid email": {"id": map[string]any{"value": "user-1"}, "email": "user@example.invalid"}, + } { + t.Run(name, func(t *testing.T) { + if _, err := findMailUserProjection(user); err == nil { + t.Fatal("malformed optional identity unexpectedly passed projection") + } + }) + } +} + +func TestCrossPlatformCoverageSmartMailWhitespaceIdentityFailsClosed(t *testing.T) { + for _, tc := range []struct { + name string + declaration shortcut.Shortcut + args []string + tool string + response string + }{ + {name: "search message id", declaration: SearchMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "triage message id", declaration: TriageMail, args: []string{"--email", "mail@example.invalid", "--query", "fixture"}, tool: "search_emails", response: `{"success":true,"messages":[{"id":" ","from":"sender@example.invalid"}],"hasMore":false,"nextCursor":""}`}, + {name: "find user identity", declaration: FindMailUser, args: []string{"--query", "fixture"}, tool: "search_mail_users", response: `{"success":true,"users":[{"id":" ","email":" "}],"hasMore":false,"nextCursor":""}`}, + } { + t.Run(tc.name, func(t *testing.T) { + caller := &smartMailCursorCaller{responses: map[string]string{tc.tool: tc.response}} + if err := runSmartMailDeclaration(t, tc.declaration, caller, tc.args...); err == nil { + t.Fatal("whitespace stable identity unexpectedly succeeded") + } + if len(caller.calls) != 1 { + t.Fatalf("calls=%d, want one response validation call", len(caller.calls)) + } + }) + } +} + +func TestCrossPlatformCoverageFindMailUserKeepsStableQueryProperty(t *testing.T) { + for _, parameter := range FindMailUser.Contract.Parameters { + if parameter.Name == "query" { + if parameter.Property != "query" { + t.Fatalf("query property = %q, want stable composite property query", parameter.Property) + } + return + } + } + t.Fatal("FindMailUser query parameter declaration is missing") +} diff --git a/internal/shortcut/smart/my_attendance.go b/internal/shortcut/smart/my_attendance.go index 1c445d14..1cbc99a5 100644 --- a/internal/shortcut/smart/my_attendance.go +++ b/internal/shortcut/smart/my_attendance.go @@ -14,12 +14,14 @@ package smart import ( + "strings" "time" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +43,11 @@ import ( // // dws attendance +my-attendance var MyAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+my-attendance", - Product: "attendance", - Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+my-attendance", + Product: "attendance", + Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己今天的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写今天的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出今天 00:00 到次日 00:00 的时间窗," + "最后查询你今天的打卡流水记录。只读操作,不会修改任何考勤数据;今天若还没有任何打卡则返回空结果。", @@ -63,9 +66,9 @@ var MyAttendance = shortcut.Shortcut{ }, Description: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-day fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我今天的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +87,7 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,15 +113,89 @@ var MyAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } -// myAttendanceCurrentUserID extracts the current user's userId from a +// strictAttendanceCurrentUserID extracts the current user's userId from a // get_current_user_profile response. The gateway returns one of several shapes, // so probe them defensively (mirrors helpers.getCurrentUserID): // - {"result": [ {"orgEmployeeModel": {"userId": ...}} ]} // - {"result": {"userId": ...}} +func strictAttendanceCurrentUserID(data map[string]any) string { + if data == nil { + return "" + } + success, ok := data["success"].(bool) + if !ok || !success { + return "" + } + identities := map[string]struct{}{} + collect := func(item map[string]any) bool { + var direct, nested string + if value, present := item["userId"]; present { + text, valid := value.(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + direct = strings.TrimSpace(text) + } + if value, present := item["orgEmployeeModel"]; present { + object, valid := value.(map[string]any) + if !valid || object == nil { + return false + } + text, valid := object["userId"].(string) + if !valid || strings.TrimSpace(text) == "" { + return false + } + nested = strings.TrimSpace(text) + } + if direct == "" && nested == "" { + return false + } + if direct != "" && nested != "" && direct != nested { + return false + } + if direct != "" { + identities[direct] = struct{}{} + } else { + identities[nested] = struct{}{} + } + return true + } + switch result := data["result"].(type) { + case []any: + if len(result) == 0 { + return "" + } + for _, item := range result { + m, ok := item.(map[string]any) + if !ok || !collect(m) { + return "" + } + } + case map[string]any: + if !collect(result) { + return "" + } + default: + return "" + } + if len(identities) != 1 { + return "" + } + var identity string + for candidate := range identities { + identity = candidate + } + return identity +} + +// myAttendanceCurrentUserID preserves the compatibility resolver used by +// older non-Attendance smart workflows. New Attendance shortcuts must use the +// strict resolver above so a missing business success flag cannot consume a +// stale result. func myAttendanceCurrentUserID(data map[string]any) string { if data == nil { return "" diff --git a/internal/shortcut/smart/my_initiated.go b/internal/shortcut/smart/my_initiated.go deleted file mode 100644 index 195fb6a9..00000000 --- a/internal/shortcut/smart/my_initiated.go +++ /dev/null @@ -1,247 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" - - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// MyInitiated: list the OA approval instances *I* have initiated (submitted), in -// one step. -// -// Steps: -// -// 1. call get_submitted_instances on the OA server — the exact tool + parameter -// names (pageNumber / pageSize as float64, optional query) used by -// helpers.approvalSubmittedListCmd ("dws oa approval list-submitted"). page -// and limit default to the same first-page values (page 1, limit 20). -// -// 2. defensively locate the instance list inside the response (probing common -// container keys, incl. a nested result/data object) and project each item -// down to {title, businessId, status, processInstanceId} with multiple -// candidate keys per field. When no recognisable list is found we print the -// raw payload so nothing is silently lost. -// -// 3. print via rt.Output so it honours --format / --jq / --fields. -// -// Read-only: it only lists and reshapes my submitted approvals, it never -// approves, rejects, revokes or mutates anything. -// -// dws oa +my-initiated -// dws oa +my-initiated --query 报销 -// dws oa +my-initiated --page 2 --limit 50 -var MyInitiated = shortcut.Shortcut{ - Service: "oa", - Command: "+my-initiated", - Product: "oa", - Description: "列出我发起(提交)的审批单据", - Intent: "当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;" + - "内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted)," + - "再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。" + - "可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。" + - "这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。", - Risk: shortcut.RiskRead, - Safety: contract.SafetySpec{ - Effect: "read", Risk: "low", - Confirmation: "not_required", Idempotency: "idempotent", - }, - Contract: corecmd.ContractDecl{ - Identity: contract.ToolIdentitySpec{ - ProductID: "oa", - Name: "shortcut_my_initiated", - CanonicalPath: "oa.shortcut_my_initiated", - CLIPath: "oa +my-initiated", - PrimaryCLIPath: "oa +my-initiated", - }, - Description: "列出我发起(提交)的审批单据", - Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", - }, - Selection: contract.SelectionSpec{ - AgentSummary: "列出我发起(提交)的审批单据", - UseWhen: []string{"当你想快速看清自己发起(提交)过哪些 OA 审批单据、方便跟进它们的进展时使用;内部直接拉取当前用户已发起的审批实例列表(等价于 dws oa approval list-submitted),再在本地把每条单据投影成标题、单号(businessId)、状态和审批实例 ID(processInstanceId) 四个关键字段。可用 --query 按关键字过滤、--page/--limit 翻页(默认第 1 页、每页 20 条)。这是纯只读操作,只做列表与本地投影,不会同意、拒绝、撤销或修改任何审批单据;若没有发起过审批则返回空列表。"}, - AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"}, - Examples: []string{ - "dws oa +my-initiated", - "dws oa +my-initiated --query 报销", - }, - }, - }, - Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "关键字搜索(可选)", Required: false}, - {Name: "page", Type: shortcut.FlagInt, Desc: "分页页码(可选,默认 1)", Default: "1", Required: false}, - {Name: "limit", Type: shortcut.FlagInt, Desc: "每页大小(可选,默认 20)", Default: "20", Required: false}, - }, - Tips: []string{ - `dws oa +my-initiated`, - `dws oa +my-initiated --query 报销`, - `dws oa +my-initiated --page 2 --limit 50`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my submitted approval instances. pageNumber/pageSize are - // float64 and query is optional, mirroring helpers.approvalSubmittedListCmd. - page := rt.Int("page") - if page <= 0 { - page = 1 - } - limit := rt.Int("limit") - if limit <= 0 { - limit = 20 - } - params := map[string]any{ - "pageNumber": float64(page), - "pageSize": float64(limit), - } - if q := strings.TrimSpace(rt.Str("query")); q != "" { - params["query"] = q - } - - data, err := rt.CallMCPData("oa", "get_submitted_instances", params) - if err != nil { - return err - } - - // Step 2 — project the instance list; fall back to the raw payload when we - // cannot locate a recognisable list. - items := myInitiatedItems(data) - if len(items) == 0 { - return rt.Output(data) - } - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - "title": myInitiatedTitle(m), - "businessId": myInitiatedBusinessID(m), - "status": myInitiatedStatus(m), - "processInstanceId": myInitiatedInstanceID(m), - }) - } - - // Step 3 — print the projected list. - return rt.Output(map[string]any{"initiated": results}) - }, -} - -// myInitiatedItems locates the instance list inside a get_submitted_instances -// response, probing common container keys at the top level and nested under a -// result/data object. Returns nil when no list is found. -func myInitiatedItems(data map[string]any) []map[string]any { - if data == nil { - return nil - } - keys := []string{"list", "instances", "processInstances", "items", "data", "records", "result", "rows"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return myInitiatedToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "instances", "processInstances", "items", "data", "records", "rows"} { - if arr, ok := inner[k2].([]any); ok { - return myInitiatedToMaps(arr) - } - } - } - } - return nil -} - -func myInitiatedToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// myInitiatedTitle reads an instance's human-readable title, tolerating the -// common title keys the gateway may use. -func myInitiatedTitle(m map[string]any) any { - for _, key := range []string{"title", "subject", "name", "processName", "formName", "instanceTitle"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedBusinessID reads an instance's business / order number, preferring -// the customer-visible business id and falling back to related identifiers. -func myInitiatedBusinessID(m map[string]any) any { - for _, key := range []string{"businessId", "bizId", "orderNo", "orderNumber", "number", "serialNumber"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedStatus reads an instance's current status/result, tolerating both -// the running status and the final result keys. -func myInitiatedStatus(m map[string]any) any { - for _, key := range []string{"status", "statusText", "processStatus", "instanceStatus", "result", "approvalResult"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedInstanceID reads an instance's processInstanceId, tolerating the -// common id keys. -func myInitiatedInstanceID(m map[string]any) any { - for _, key := range []string{"processInstanceId", "instanceId", "processInstanceID", "id", "bizId"} { - if v := myInitiatedString(m[key]); v != "" { - return v - } - } - return nil -} - -// myInitiatedString coerces a scalar JSON value to a trimmed string, returning -// "" for nil / non-scalar / empty values. -func myInitiatedString(v any) string { - switch typed := v.(type) { - case string: - return strings.TrimSpace(typed) - case float64: - // approval ids/numbers may arrive as JSON numbers; render without a - // trailing decimal point when integral. - return strconv.FormatFloat(typed, 'f', -1, 64) - case int64: - return strconv.FormatInt(typed, 10) - case int: - return strconv.Itoa(typed) - case bool: - if typed { - return "true" - } - return "false" - default: - return "" - } -} - -func init() { - shortcut.Register(MyInitiated) -} diff --git a/internal/shortcut/smart/pending_approvals.go b/internal/shortcut/smart/pending_approvals.go deleted file mode 100644 index 3c4e34bd..00000000 --- a/internal/shortcut/smart/pending_approvals.go +++ /dev/null @@ -1,128 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// PendingApprovals: READ-ONLY list of the approvals waiting for ME, projected to -// clean fields. Unlike `oa +approve-by` (which actually agrees to an approval), -// this shortcut only looks — it never approves / rejects / mutates anything. -// -// Steps (tool name + param keys copied verbatim from helpers/oa.go): -// -// 1. list my pending approvals via list_pending_approvals with starTime / -// endTime as float64 milliseconds over a recent (~90 day) window, mirroring -// `dws oa approval list-pending`; optional --limit maps to pageSize (float64). -// -// 2. defensively unwrap the returned instance list (multiple candidate -// container keys, one nested level) and project each entry to a readable -// shape {title, originatorName, processInstanceId, createTime} — every field -// probed across several candidate keys. -// -// 3. if nothing is pending, report "当前没有待我审批的任务" instead of an empty -// raw dump. -// -// dws oa +pending -// dws oa +pending --limit 10 -var PendingApprovals = shortcut.Shortcut{ - Service: "oa", - Command: "+pending", - Product: "oa", - Description: "只读列出待我审批的审批任务并投影为可读列表(只看不批)", - Intent: "当你只想快速看一眼「待我审批」的审批任务清单——每条的标题、发起人、审批实例 ID 和创建时间——" + - "而不想拿到一大坨原始字段时使用;内部拉取你近三个月待处理的审批单,再在本地投影出可读字段。" + - "这是纯只读操作,只做列出与本地投影,绝不会同意、拒绝或以任何方式提交/修改审批(要一键通过请改用 `dws oa +approve-by`);" + - "若当前没有待你审批的任务则提示「当前没有待我审批的任务」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "limit", Type: shortcut.FlagInt, Desc: "最多列出多少条(可选)", Required: false}, - }, - Tips: []string{ - `dws oa +pending`, - `dws oa +pending --limit 10`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list my pending approvals. starTime/endTime are float64 - // milliseconds; pageSize is optional. Keys copied verbatim from - // helpers.list-pending (list_pending_approvals). Window: last ~90 days. - now := time.Now() - params := map[string]any{ - "starTime": float64(now.AddDate(0, 0, -90).UnixMilli()), - "endTime": float64(now.UnixMilli()), - } - if rt.Changed("limit") { - if n := rt.Int("limit"); n > 0 { - params["pageSize"] = float64(n) - } - } - data, err := rt.CallMCPData("oa", "list_pending_approvals", params) - if err != nil { - return err - } - - // Step 2 — project entries. shortcutApproveItems (approve.go) defensively - // unwraps the list container (result/list/instances/items/data/records/ - // processList, incl. one nested level). - items := shortcutApproveItems(data) - results := make([]map[string]any, 0, len(items)) - for _, m := range items { - results = append(results, map[string]any{ - // shortcutApproveTitle probes title/processTitle/name/subject/… - "title": shortcutApproveTitle(m), - // shortcutApproveInstanceID probes processInstanceId/instanceId/… - "processInstanceId": shortcutApproveInstanceID(m), - "originatorName": pendingApprovalsOriginator(m), - "createTime": pendingApprovalsCreateTime(m), - }) - } - - // Step 3 — empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("当前没有待我审批的任务") - } - - return rt.Output(map[string]any{"count": len(results), "pending": results}) - }, -} - -// pendingApprovalsOriginator reads the initiator's display name, tolerating the -// common field names the gateway may use. -func pendingApprovalsOriginator(m map[string]any) string { - for _, key := range []string{"originatorName", "originatorUserName", "creatorName", "creator", "applicantName", "userName"} { - if s := shortcutApproveStr(m[key]); s != "" { - return s - } - } - return "" -} - -// pendingApprovalsCreateTime reads a pending approval's create time, returning -// the raw value (usually epoch millis) under whichever candidate key is present. -func pendingApprovalsCreateTime(m map[string]any) any { - for _, key := range []string{"createTime", "gmtCreate", "createTimeStr", "startTime", "createdAt"} { - if v, ok := m[key]; ok && v != nil { - return v - } - } - return nil -} - -func init() { - shortcut.Register(PendingApprovals) -} diff --git a/internal/shortcut/smart/recent_mail.go b/internal/shortcut/smart/recent_mail.go index c5b920ed..5651fdc8 100644 --- a/internal/shortcut/smart/recent_mail.go +++ b/internal/shortcut/smart/recent_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -71,6 +74,12 @@ var RecentMail = shortcut.Shortcut{ PrimaryCLIPath: "mail +recent-mail", }, Description: "列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId)", + Parameters: []contract.ParamDecl{ + {Name: "limit", Property: "limit"}, + {Name: "email", Property: "email"}, + {Name: "folder", Property: "folder"}, + {Name: "cursor", Property: "cursor"}, + }, Interface: &contract.InterfaceSpec{ Mode: "composite", Availability: "available", @@ -90,6 +99,11 @@ var RecentMail = shortcut.Shortcut{ {Name: "limit", Type: shortcut.FlagInt, Desc: "返回会话条数上限(可选,默认 20,最大 100)", Required: false}, {Name: "email", Type: shortcut.FlagString, Desc: "要查看的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, {Name: "folder", Type: shortcut.FlagString, Desc: "文件夹 ID(可选,默认定位收件箱)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "显式 --limit 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", false) }, Tips: []string{ `dws mail +recent-mail`, @@ -119,40 +133,47 @@ var RecentMail = shortcut.Shortcut{ // Step 3 — list threads. email/folderId/size mirror the helpers.mail // thread-list call; size is an int in 1..100. - size := rt.Int("limit") - if size <= 0 { - size = 20 + limit := 20 + if rt.Changed("limit") { + limit = rt.Int("limit") } - if size > 100 { - size = 100 - } - data, err := rt.CallMCPData("mail", "list_mailbox_threads", map[string]any{ + args := map[string]any{ "email": email, "folderId": folderID, - "size": size, - }) + "size": limit, + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "list_mailbox_threads", args) if err != nil { return err } // Step 4 — project each conversation to a compact record. - threads := searchMailUnwrapList(data, "conversations", "threads", "result", "data", "list", "items", "records") + threads, err := smartMailRows(data, "mail/list_mailbox_threads", "result.conversations", "id") + if err != nil { + return err + } results := make([]map[string]any, 0, len(threads)) for _, t := range threads { + from, err := recentMailSenders(t) + if err != nil { + return err + } results = append(results, map[string]any{ "subject": searchMailFirstString(t, "subject", "title", "topic"), - "from": recentMailSenders(t), + "from": from, "date": searchMailFirstAny(t, "lastModifiedDateTime", "date", "sentTime", "sentDate", "receivedDate", "createTime"), "threadId": searchMailFirstString(t, "threadId", "id", "conversationId"), }) } - // Empty result guard. - if len(results) == 0 { - return apperrors.NewValidation("最近没有邮件") + complete, next, err := smartMailPage(data, "mail/list_mailbox_threads", "result", rt.Str("cursor")) + if err != nil { + return err } - - return rt.Output(map[string]any{"count": len(results), "mails": results}) + return smartMailOutputPage(rt, "mails", results, complete, next) }, } @@ -166,12 +187,15 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e if err != nil { return "", err } - folders := searchMailUnwrapList(data, "folders", "list", "items", "data", "result", "records") + folders, err := smartMailRows(data, "mail/list_folders", "folders", "id") + if err != nil { + return "", err + } inboxNames := map[string]bool{ "收件箱": true, "inbox": true, } for _, f := range folders { - name := searchMailFirstString(f, "displayName", "name", "folderName") + name := strings.ToLower(strings.TrimSpace(searchMailFirstString(f, "displayName", "name", "folderName"))) if inboxNames[name] { if id := searchMailFirstString(f, "id", "folderId"); id != "" { return id, nil @@ -184,16 +208,14 @@ func recentMailInboxFolder(rt *shortcut.RuntimeContext, email string) (string, e // recentMailSenders reads a conversation's sender list. The helper documents a // "senders" array of {email, name}; we tolerate a plain string, a single object, // and fall back to the shared searchMailFrom logic. -func recentMailSenders(t map[string]any) any { - if arr, ok := t["senders"].([]any); ok && len(arr) > 0 { +func recentMailSenders(t map[string]any) (any, error) { + raw, present := t["senders"] + if arr, ok := raw.([]any); ok && len(arr) > 0 { names := make([]string, 0, len(arr)) - for _, it := range arr { + for index, it := range arr { m, ok := it.(map[string]any) - if !ok { - if s, ok := it.(string); ok && s != "" { - names = append(names, s) - } - continue + if !ok || len(m) == 0 { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 不是非空对象", index)) } name := searchMailFirstString(m, "name", "displayName") addr := searchMailFirstString(m, "email", "emailAddress", "address") @@ -204,15 +226,23 @@ func recentMailSenders(t map[string]any) any { names = append(names, addr) case name != "": names = append(names, name) + default: + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", fmt.Sprintf("senders[%d] 缺少姓名或邮箱", index)) } } if len(names) > 0 { - return names + return names, nil + } + } else if present { + if _, ok := raw.([]any); !ok { + return nil, smartMailError("mail/list_mailbox_threads", "malformed_sender", "senders 应为数组") } } return searchMailFrom(t) } func init() { + hardenSmartMail(&RecentMail, "mails", "严格校验的近期邮件会话摘要") + markSmartMailCompatibilityOnly(&RecentMail) shortcut.Register(RecentMail) } diff --git a/internal/shortcut/smart/report_latest.go b/internal/shortcut/smart/report_latest.go deleted file mode 100644 index c756573a..00000000 --- a/internal/shortcut/smart/report_latest.go +++ /dev/null @@ -1,220 +0,0 @@ -// Copyright 2026 Alibaba Group -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -package smart - -import ( - "strconv" - "strings" - "time" - - apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" - "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" -) - -// ReportLatest: fetch the detail of MY most recently submitted report (日志/汇报) -// in one step (DingTalk-native). -// -// Steps: -// -// 1. list the reports I sent via get_send_report_list. cursor/size/startTime/ -// endTime mirror helpers.runReportSent — the server caps a single query span -// at 20 days, so we default to the last-20-days window just like the helper. -// An optional --keyword is passed through as report_template_name (the same -// filter the helper exposes as --template-name). -// -// 2. locate the newest entry: the item with the largest create time -// (createTime/gmtCreate/sendTime), falling back to the first item that -// carries a reportId (lists come back newest-first). -// -// 3. print that report's body via get_report_entry_details (report_id param -// mirrors helpers.runReportDetail). If nothing carries a reportId we fall -// back to printing the picked list row via rt.Output. -// -// If I have not sent any report in the window it reports "暂无日志" instead of -// failing obscurely. -// -// dws report +report-latest -// dws report +report-latest --keyword 日报 -var ReportLatest = shortcut.Shortcut{ - Service: "report", - Command: "+report-latest", - Product: "report", - Description: "取我最新提交的一篇日志/汇报详情(钉钉原生)", - Intent: "当你只想快速看回自己最近发出的一篇日志/汇报,却不想先翻发件箱列表、复制 reportId 再查详情时使用;" + - "内部先列出你近期发出的日志(默认最近 20 天,服务端单次查询跨度上限 20 天;可用 --keyword 按日志模板名过滤)," + - "自动挑出创建时间最新的一条,再拉取它的正文详情(字段明细、发送人、时间、钉钉跳转链接等)。" + - "这是只读操作,不会创建或修改任何日志;若这段时间内你没有发出过日志则提示「暂无日志」。", - Risk: shortcut.RiskRead, - Flags: []shortcut.Flag{ - {Name: "keyword", Type: shortcut.FlagString, Desc: "按日志模板名过滤(可选,对应 report outbox list 的 --template-name)", Required: false}, - }, - Tips: []string{ - `dws report +report-latest`, - `dws report +report-latest --keyword 日报`, - }, - Execute: func(rt *shortcut.RuntimeContext) error { - // Step 1 — list the reports I sent. Mirror helpers.runReportSent: the - // server caps a single get_send_report_list query at a 20-day span, so - // default startTime/endTime to the last-20-days window. cursor/size are - // floats and report_template_name is the optional keyword filter. - now := time.Now() - startMs := now.AddDate(0, 0, -20).Truncate(24 * time.Hour).UnixMilli() - endMs := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, now.Location()).UnixMilli() - - listArgs := map[string]any{ - "cursor": float64(0), - "size": float64(20), - "startTime": float64(startMs), - "endTime": float64(endMs), - } - if kw := strings.TrimSpace(rt.Str("keyword")); kw != "" { - listArgs["report_template_name"] = kw - } - - data, err := rt.CallMCPData("report", "get_send_report_list", listArgs) - if err != nil { - return err - } - - // Step 2 — pick the newest sent report. - row, reportID := shortcutReportLatestPick(data) - if row == nil { - return apperrors.NewValidation("暂无日志") - } - - // Step 3 — print its detail (report_id mirrors helpers.runReportDetail). - // If we somehow could not read a reportId, fall back to the list row. - if reportID == "" { - return rt.Output(row) - } - return rt.CallMCP("get_report_entry_details", map[string]any{ - "report_id": reportID, - }) - }, -} - -// shortcutReportLatestPick walks a get_send_report_list response, finds the -// report entries, and returns the newest one (largest create time, falling back -// to the first entry that carries a reportId) together with its reportId. -// Returns (nil, "") when there are no entries. -func shortcutReportLatestPick(data map[string]any) (map[string]any, string) { - items := shortcutReportLatestItems(data) - if len(items) == 0 { - return nil, "" - } - - var firstItem map[string]any - firstID := "" - var bestItem map[string]any - bestID := "" - var bestTime int64 - haveTime := false - - for _, m := range items { - id := shortcutReportLatestID(m) - if id == "" { - continue - } - if firstItem == nil { - firstItem = m - firstID = id - } - if t, ok := shortcutReportLatestCreateMillis(m); ok { - if !haveTime || t > bestTime { - haveTime = true - bestTime = t - bestItem = m - bestID = id - } - } - } - - if haveTime && bestItem != nil { - return bestItem, bestID - } - if firstItem != nil { - return firstItem, firstID - } - // No entry carried a reportId; still surface the first raw item so the - // caller can print something rather than mis-report "暂无日志". - return items[0], "" -} - -// shortcutReportLatestItems pulls the report entries out of a -// get_send_report_list response, probing the same container keys the helper's -// findReportListItems uses (list/items/data/reports/records/report_list/ -// reportList nested under the top level or under result). -func shortcutReportLatestItems(data map[string]any) []map[string]any { - keys := []string{"list", "items", "data", "reports", "records", "report_list", "reportList", "result"} - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range []string{"list", "items", "data", "reports", "records", "report_list", "reportList"} { - if arr, ok := inner[k2].([]any); ok { - return shortcutReportLatestToMaps(arr) - } - } - } - } - return nil -} - -func shortcutReportLatestToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } - } - return out -} - -// shortcutReportLatestID reads an entry's reportId, mirroring the key aliases in -// helpers.reportIDFromMap. -func shortcutReportLatestID(m map[string]any) string { - for _, key := range []string{"reportId", "reportID", "report_id", "report_Id", "report-id"} { - if id, ok := m[key].(string); ok { - if id = strings.TrimSpace(id); id != "" { - return id - } - } - } - return "" -} - -// shortcutReportLatestCreateMillis reads an entry's create time in millis, -// mirroring the create-time keys helpers.reportReadableCreateTimeMillis probes. -func shortcutReportLatestCreateMillis(m map[string]any) (int64, bool) { - for _, key := range []string{"createTime", "gmtCreate", "sendTime", "time", "modifiedTime"} { - switch v := m[key].(type) { - case float64: - if v > 0 { - return int64(v), true - } - case string: - if s := strings.TrimSpace(v); s != "" { - if n, err := strconv.ParseInt(s, 10, 64); err == nil && n > 0 { - return n, true - } - } - } - } - return 0, false -} - -func init() { - shortcut.Register(ReportLatest) -} diff --git a/internal/shortcut/smart/search_mail.go b/internal/shortcut/smart/search_mail.go index a6d824e2..d0d2a22b 100644 --- a/internal/shortcut/smart/search_mail.go +++ b/internal/shortcut/smart/search_mail.go @@ -14,6 +14,9 @@ package smart import ( + "fmt" + "strings" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" @@ -79,9 +82,20 @@ var SearchMail = shortcut.Shortcut{ }, }, Flags: []shortcut.Flag{ - {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2)", Required: true}, + {Name: "query", Type: shortcut.FlagString, Desc: "KQL 搜索表达式(如 subject:周报、from:alice、folderId:2),不能为空", Required: true}, {Name: "email", Type: shortcut.FlagString, Desc: "要搜索的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{ + {Kind: shortcut.ConstraintCustom, Flags: []string{"query"}, Description: "不能为空"}, + {Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "1-100"}, + }, + Validate: func(rt *shortcut.RuntimeContext) error { + if err := smartMailValidateRequiredText(rt, "query"); err != nil { + return err + } + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +search-mail --query "subject:周报"`, @@ -92,6 +106,10 @@ var SearchMail = shortcut.Shortcut{ if err := rt.RequireAll("query"); err != nil { return err } + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") @@ -105,31 +123,42 @@ var SearchMail = shortcut.Shortcut{ // Step 2 — search that mailbox. email/query/size mirror the search_emails // call in helpers.messageSearch; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": rt.Str("query"), "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record. - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } @@ -142,115 +171,106 @@ func searchMailFirstMailbox(rt *shortcut.RuntimeContext) (string, error) { if err != nil { return "", err } - if addr := searchMailPickMailbox(data); addr != "" { - return addr, nil + mailboxes, err := searchMailMailboxAddresses(data) + if err != nil { + return "", err + } + if len(mailboxes) > 0 { + return mailboxes[0], nil } return "", apperrors.NewValidation("未找到可用邮箱,请用 --email 指定要搜索的邮箱地址") } -// searchMailPickMailbox resolves the first usable mailbox address from a -// list_user_mailboxes response. It tolerates every observed shape: objects with -// an email field (result.emailAccounts[].email) and bare string arrays -// (result.emailAccounts[]), each at the top level or one level under a -// result/data wrapper. searchMailToMaps drops plain strings, so the bare-string -// case is scanned separately — otherwise all three smart mail shortcuts wrongly -// report that no mailbox exists. -func searchMailPickMailbox(data map[string]any) string { - containers := []string{"emailAccounts", "mailboxes", "list", "items", "data", "result", "records"} - for _, m := range searchMailUnwrapList(data, containers...) { - if addr := searchMailFirstString(m, "email", "emailAddress", "mailbox", "address", "account"); addr != "" { - return addr - } +// searchMailMailboxAddresses accepts the two reviewed mailbox wire shapes: +// non-empty address strings and non-empty objects carrying an email field. +// The collection itself and every item remain fail-closed, so malformed rows +// cannot be skipped and misreported as an empty mailbox list. +func searchMailMailboxAddresses(data map[string]any) ([]string, error) { + const operation = "mail/list_user_mailboxes" + if err := smartMailSuccess(data, operation); err != nil { + return nil, err } - scanStrings := func(m map[string]any) string { - for _, key := range containers { - if arr, ok := m[key].([]any); ok { - for _, it := range arr { - if s, ok := it.(string); ok && s != "" { - return s - } - } - } + paths := []string{"emailAccounts", "result.emailAccounts", "data.emailAccounts"} + var value any + selectedPath := "" + for _, path := range paths { + candidate, present := smartMailLookup(data, path) + if !present { + continue } - return "" - } - if s := scanStrings(data); s != "" { - return s - } - for _, wrap := range []string{"result", "data"} { - if inner, ok := data[wrap].(map[string]any); ok { - if s := scanStrings(inner); s != "" { - return s - } + if selectedPath != "" { + return nil, smartMailError(operation, "conflicting_collection", fmt.Sprintf("响应同时包含 %s 与 %s,无法选择唯一邮箱集合", selectedPath, path)) } + value = candidate + selectedPath = path } - return "" -} - -// searchMailMessages extracts the message list from a search_emails response. -// The helper documents the list under "messages". -func searchMailMessages(data map[string]any) []map[string]any { - return searchMailUnwrapList(data, "messages", "emails", "list", "items", "data", "result", "records") -} - -// searchMailUnwrapList probes the given container keys at the top level, and one -// level deep, returning the first list found as []map[string]any. -func searchMailUnwrapList(data map[string]any, keys ...string) []map[string]any { - for _, key := range keys { - if arr, ok := data[key].([]any); ok { - return searchMailToMaps(arr) - } - if inner, ok := data[key].(map[string]any); ok { - for _, k2 := range keys { - if arr, ok := inner[k2].([]any); ok { - return searchMailToMaps(arr) - } - } - } + if selectedPath == "" { + return nil, smartMailError(operation, "missing_collection", "成功响应缺少 emailAccounts 数组;不能把未知响应结构当作空结果") } - return nil -} - -func searchMailToMaps(arr []any) []map[string]any { - out := make([]map[string]any, 0, len(arr)) - for _, it := range arr { - if m, ok := it.(map[string]any); ok { - out = append(out, m) - } + raw, ok := value.([]any) + if !ok { + return nil, smartMailError(operation, "malformed_collection", fmt.Sprintf("响应 %s 应为数组,实际为 %T", selectedPath, value)) } - return out + addresses := make([]string, 0, len(raw)) + for index, item := range raw { + var address string + switch typed := item.(type) { + case string: + address = strings.TrimSpace(typed) + case map[string]any: + address = searchMailFirstString(typed, "email") + default: + return nil, smartMailError(operation, "malformed_item", fmt.Sprintf("响应 %s[%d] 必须是邮箱字符串或含 email 的对象", selectedPath, index)) + } + if address == "" { + return nil, smartMailError(operation, "missing_item_identity", fmt.Sprintf("响应 %s[%d] 缺少非空邮箱地址", selectedPath, index)) + } + addresses = append(addresses, address) + } + return addresses, nil } // searchMailFrom reads a message's sender, tolerating both a plain string and a // nested object ({name, email/address}). -func searchMailFrom(m map[string]any) any { - switch v := m["from"].(type) { +func searchMailFrom(m map[string]any) (any, error) { + raw, present := m["from"] + switch v := raw.(type) { case string: - if v != "" { - return v + if normalized := strings.TrimSpace(v); normalized != "" { + return normalized, nil } case map[string]any: name := searchMailFirstString(v, "name", "displayName") addr := searchMailFirstString(v, "email", "emailAddress", "address") switch { case name != "" && addr != "": - return name + " <" + addr + ">" + return name + " <" + addr + ">", nil case addr != "": - return addr + return addr, nil case name != "": - return name + return name, nil } + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 对象缺少姓名或邮箱") + case nil: + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为 null") + } + default: + return nil, smartMailError("mail/search_emails", "malformed_sender", fmt.Sprintf("from 字段类型错误:%T", raw)) } if s := searchMailFirstString(m, "sender", "fromAddress", "fromName", "fromEmail"); s != "" { - return s + return s, nil } - return "" + if present { + return nil, smartMailError("mail/search_emails", "malformed_sender", "from 不能为空") + } + return nil, smartMailError("mail/search_emails", "missing_sender", "消息缺少可验证的发件人字段") } func searchMailFirstString(m map[string]any, keys ...string) string { for _, key := range keys { - if s, ok := m[key].(string); ok && s != "" { - return s + if s, ok := m[key].(string); ok && strings.TrimSpace(s) != "" { + return strings.TrimSpace(s) } } return "" @@ -259,8 +279,11 @@ func searchMailFirstString(m map[string]any, keys ...string) string { func searchMailFirstAny(m map[string]any, keys ...string) any { for _, key := range keys { if v, ok := m[key]; ok && v != nil { - if s, isStr := v.(string); isStr && s == "" { - continue + if s, isStr := v.(string); isStr { + if strings.TrimSpace(s) == "" { + continue + } + return strings.TrimSpace(s) } return v } @@ -269,5 +292,6 @@ func searchMailFirstAny(m map[string]any, keys ...string) any { } func init() { + hardenSmartMail(&SearchMail, "messages", "严格校验的邮件搜索摘要") shortcut.Register(SearchMail) } diff --git a/internal/shortcut/smart/this_month_attendance.go b/internal/shortcut/smart/this_month_attendance.go index 3363e6f8..641c5576 100644 --- a/internal/shortcut/smart/this_month_attendance.go +++ b/internal/shortcut/smart/this_month_attendance.go @@ -20,6 +20,7 @@ import ( "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/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" ) @@ -41,10 +42,11 @@ import ( // // dws attendance +this-month var ThisMonthAttendance = shortcut.Shortcut{ - Service: "attendance", - Command: "+this-month", - Product: "attendance", - Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", + OutputRollout: output.RolloutLegacyOnly, + Service: "attendance", + Command: "+this-month", + Product: "attendance", + Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Intent: "当你想快速看自己本月的打卡流水(几点上下班打卡、打卡地址/定位方式)、又不想先查自己的 userId " + "再手动填写本月的起止时间时使用;内部先取当前登录用户的 userId,再按本地时区算出本月 1 号 00:00 到下月 1 号 00:00 的时间窗," + "最后查询你本月的打卡流水记录。只读操作,不会修改任何考勤数据;本月若还没有任何打卡则返回空结果。", @@ -63,9 +65,9 @@ var ThisMonthAttendance = shortcut.Shortcut{ }, Description: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", Interface: &contract.InterfaceSpec{ - Mode: "composite", - Availability: "available", - Reason: "Reviewed built-in shortcut adapter: the executable CLI owns validation, optional multi-step orchestration, output projection, and confirmation; the complete command contract is not represented by one pinned MCP interface_ref.", + Mode: contract.InterfaceModeComposite, + Availability: contract.InterfaceAvailable, + Reason: "Historical executable Schema compatibility: this command remains callable, but the reviewed Shortcut catalog keeps it non-public until a known-nonempty current-month fixture closes the false-empty proof gap.", }, Selection: contract.SelectionSpec{ AgentSummary: "查我本月的考勤打卡记录(打卡流水,自动解析当前用户)", @@ -84,7 +86,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - userID := myAttendanceCurrentUserID(profile) + userID := strictAttendanceCurrentUserID(profile) if userID == "" { return apperrors.NewValidation( "没能解析出当前登录用户的 userId,无法查询你的打卡记录;请确认已登录后重试。") @@ -110,7 +112,7 @@ var ThisMonthAttendance = shortcut.Shortcut{ if err != nil { return err } - return rt.Output(data) + return outputStrictAttendanceRecords(rt, data) }, } diff --git a/internal/shortcut/smart/triage_mail.go b/internal/shortcut/smart/triage_mail.go new file mode 100644 index 00000000..7f32ee13 --- /dev/null +++ b/internal/shortcut/smart/triage_mail.go @@ -0,0 +1,102 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package smart + +import ( + "strconv" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut" +) + +// TriageMail aligns the Lark task-level triage entry while preserving DWS's +// easier mailbox and inbox resolution. It returns summaries only; callers use +// +message or +messages for full bodies. +var TriageMail = shortcut.Shortcut{ + Service: "mail", Command: "+triage", Product: "mail", + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", Intent: "快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。", + Risk: shortcut.RiskRead, + Safety: contract.SafetySpec{ + Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent", + }, + Contract: corecmd.ContractDecl{ + Description: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + Identity: contract.ToolIdentitySpec{ + ProductID: "mail", Name: "shortcut_triage", CanonicalPath: "mail.shortcut_triage", + CLIPath: "mail +triage", PrimaryCLIPath: "mail +triage", + }, + Interface: &contract.InterfaceSpec{Mode: contract.InterfaceModeComposite, Availability: contract.InterfaceAvailable, Reason: "Resolves the current mailbox and inbox locally, then strictly validates search_emails summaries and pagination."}, + Selection: contract.SelectionSpec{ + AgentSummary: "列出或筛选邮件摘要,自动解析邮箱与收件箱", + UseWhen: []string{"快速浏览收件箱摘要,或用 KQL 条件筛选邮件;不提供条件时自动定位当前邮箱的收件箱,返回可继续交给 +message/+messages 的稳定 ID。"}, + AvoidWhen: []string{"已知单个 messageId 并要读正文时用 mail +message;需要多封完整正文时用 mail +messages"}, + Examples: []string{`dws mail +triage --limit 20 --format json`, `dws mail +triage --query "isRead:false" --format json`}, + }, + Parameters: []contract.ParamDecl{{Name: "limit", Property: "size"}, {Name: "cursor", Property: "cursor"}, {Name: "query", Property: "query"}}, + }, + Flags: []shortcut.Flag{ + {Name: "query", Type: shortcut.FlagString, Desc: "可选 KQL 条件;不传时列出收件箱"}, + {Name: "email", Type: shortcut.FlagString, Desc: "邮箱地址;不传时自动取当前身份首个邮箱"}, + {Name: "limit", Type: shortcut.FlagInt, Default: "20", Desc: "每页摘要数量,必须在 1-100 之间"}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标"}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"limit"}, Description: "1-100"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidatePageSize(rt, "limit", true) + }, + Execute: func(rt *shortcut.RuntimeContext) error { + email := rt.Str("email") + if email == "" { + resolved, err := searchMailFirstMailbox(rt) + if err != nil { + return err + } + email = resolved + } + query := rt.Str("query") + if query == "" { + folderID, err := recentMailInboxFolder(rt, email) + if err != nil { + return err + } + query = "folderId:" + folderID + } + args := map[string]any{"email": email, "query": query, "size": strconv.Itoa(rt.Int("limit"))} + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) + if err != nil { + return err + } + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } + rows := make([]map[string]any, 0, len(messages)) + for _, message := range messages { + from, err := searchMailFrom(message) + if err != nil { + return err + } + rows = append(rows, map[string]any{ + "messageId": searchMailFirstString(message, "id"), + "subject": searchMailFirstString(message, "subject"), + "from": from, + "date": searchMailFirstAny(message, "receivedDateTime", "date", "sentDateTime"), + }) + } + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", rows, complete, next) + }, +} + +func init() { + hardenSmartMail(&TriageMail, "messages", "严格校验的邮件摘要与分页证据") + shortcut.Register(TriageMail) +} diff --git a/internal/shortcut/smart/unread_mail.go b/internal/shortcut/smart/unread_mail.go index e72a91fa..8299663d 100644 --- a/internal/shortcut/smart/unread_mail.go +++ b/internal/shortcut/smart/unread_mail.go @@ -80,7 +80,12 @@ var UnreadMail = shortcut.Shortcut{ }, Flags: []shortcut.Flag{ {Name: "email", Type: shortcut.FlagString, Desc: "要查询的邮箱地址(可选,默认取你绑定的第一个邮箱)", Required: false}, - {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20)", Required: false}, + {Name: "size", Type: shortcut.FlagString, Desc: "返回条数上限(可选,默认 20;显式提供时必须是 1-100 之间的整数)", Required: false}, + {Name: "cursor", Type: shortcut.FlagString, Desc: "分页游标,取自上一页 nextCursor", Required: false}, + }, + Constraints: []shortcut.Constraint{{Kind: shortcut.ConstraintCustom, Flags: []string{"size"}, Description: "显式 --size 必须在 1-100 之间"}}, + Validate: func(rt *shortcut.RuntimeContext) error { + return smartMailValidateStringPageSize(rt, "size") }, Tips: []string{ `dws mail +unread-mail`, @@ -88,6 +93,10 @@ var UnreadMail = shortcut.Shortcut{ `dws mail +unread-mail --size 50`, }, Execute: func(rt *shortcut.RuntimeContext) error { + size, err := smartMailStringPageSize(rt, "size", "20") + if err != nil { + return err + } // Step 1 — resolve the mailbox address. email := rt.Str("email") if email == "" { @@ -101,34 +110,47 @@ var UnreadMail = shortcut.Shortcut{ // Step 2 — search unread mail. email/query/size mirror the search_emails // call in helpers.messageSearch; the isRead:false KQL filter matches the // isRead field documented there; size is passed as a string. - size := rt.Str("size") - if size == "" { - size = "20" - } - data, err := rt.CallMCPData("mail", "search_emails", map[string]any{ + args := map[string]any{ "email": email, "query": "isRead:false", "size": size, - }) + } + if rt.Changed("cursor") { + args["cursor"] = rt.Str("cursor") + } + data, err := rt.CallMCPData("mail", "search_emails", args) if err != nil { return err } // Step 3 — project each message to a compact record (shared helpers). - messages := searchMailMessages(data) + messages, err := smartMailSearchRows(data, "mail/search_emails") + if err != nil { + return err + } out := make([]map[string]any, 0, len(messages)) for _, m := range messages { + from, err := searchMailFrom(m) + if err != nil { + return err + } out = append(out, map[string]any{ "subject": searchMailFirstString(m, "subject", "title", "topic"), - "from": searchMailFrom(m), + "from": from, "date": searchMailFirstAny(m, "date", "sentDate", "receivedDate", "sentTime", "internalDate", "createTime"), "messageId": searchMailFirstString(m, "messageId", "id", "mailId", "emailId", "internetMessageId"), }) } - return rt.Output(map[string]any{"messages": out, "email": email}) + complete, next, err := smartMailPage(data, "mail/search_emails", "", rt.Str("cursor")) + if err != nil { + return err + } + return smartMailOutputPage(rt, "messages", out, complete, next) }, } func init() { + hardenSmartMail(&UnreadMail, "messages", "严格校验的未读邮件摘要") + markSmartMailCompatibilityOnly(&UnreadMail) shortcut.Register(UnreadMail) } diff --git a/internal/shortcut/todo/lifecycle.go b/internal/shortcut/todo/lifecycle.go index 164aede6..2e2937a7 100644 --- a/internal/shortcut/todo/lifecycle.go +++ b/internal/shortcut/todo/lifecycle.go @@ -6,6 +6,7 @@ package todo import ( "encoding/json" "fmt" + "math" "strconv" "strings" "time" @@ -164,13 +165,55 @@ func updateTodo(rt *shortcut.RuntimeContext) error { if key == "taskId" { continue } - if fmt.Sprint(detail[key]) != fmt.Sprint(expected) { + if !todoUpdateFieldMatches(key, detail[key], expected) { return todoWriteResponseError("todo/update_todo_task", "verification_mismatch", "更新后读回字段 "+key+" 不一致") } } return rt.Output(map[string]any{"taskId": taskID, "verified": true, "todo": detail}) } +func todoUpdateFieldMatches(key string, actual, expected any) bool { + switch key { + case "dueTime", "priority": + actualNumber, actualOK := todoExactInteger(actual) + expectedNumber, expectedOK := todoExactInteger(expected) + return actualOK && expectedOK && actualNumber == expectedNumber + case "subject": + actualText, actualOK := actual.(string) + expectedText, expectedOK := expected.(string) + return actualOK && expectedOK && actualText == expectedText + default: + return false + } +} + +func todoExactInteger(value any) (int64, bool) { + switch typed := value.(type) { + case int: + return int64(typed), true + case int64: + return typed, true + case float64: + return todoExactFloat(typed) + case json.Number: + parsed, err := typed.Int64() + return parsed, err == nil + case string: + parsed, err := strconv.ParseInt(strings.TrimSpace(typed), 10, 64) + return parsed, err == nil + default: + return 0, false + } +} + +func todoExactFloat(value float64) (int64, bool) { + if math.IsNaN(value) || math.IsInf(value, 0) || math.Trunc(value) != value || + value < float64(math.MinInt64) || value >= float64(math.MaxInt64) { + return 0, false + } + return int64(value), true +} + var Update = shortcut.Shortcut{ OutputRollout: output.RolloutUnifiedActive, Service: "todo", Command: "+update", Product: "todo", diff --git a/internal/shortcut/todo/page_size_coverage_test.go b/internal/shortcut/todo/page_size_coverage_test.go new file mode 100644 index 00000000..c2deb5ba --- /dev/null +++ b/internal/shortcut/todo/page_size_coverage_test.go @@ -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) + } +} diff --git a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go index f04f7c85..85ae973a 100644 --- a/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go +++ b/internal/shortcut/todo/todo_additional_cross_platform_coverage_test.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "io" + "math" "strconv" "strings" "testing" @@ -313,11 +314,44 @@ func TestCrossPlatformCoverageTodoCreateAndUpdate(t *testing.T) { } updateSuccess := &todoCoverageCaller{responses: map[string][]string{ "update_todo_task": {`{"success":true}`}, - "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40}}}`}, + "get_todo_detail": {`{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"x","priority":40,"dueTime":1786932000000}}}`}, }} - if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--yes"); err != nil { + if err := runTodoCoverage(t, Update, updateSuccess, "--task-id", "task-1", "--title", "x", "--priority", "40", "--due", todoCoverageTime, "--yes"); err != nil { t.Fatal(err) } + if todoUpdateFieldMatches("dueTime", float64(1786932000000), int64(1786932000001)) { + t.Fatal("different dueTime milliseconds matched") + } + for _, tc := range []struct { + value any + want int64 + ok bool + }{ + {value: int(40), want: 40, ok: true}, + {value: int64(1786932000000), want: 1786932000000, ok: true}, + {value: float64(40), want: 40, ok: true}, + {value: json.Number("40"), want: 40, ok: true}, + {value: " 40 ", want: 40, ok: true}, + {value: float64(math.MinInt64), want: math.MinInt64, ok: true}, + {value: 1786932000000.5}, + {value: math.NaN()}, + {value: math.Inf(1)}, + {value: math.Inf(-1)}, + {value: float64(math.MaxInt64)}, + {value: 1e300}, + {value: -1e300}, + {value: json.Number("not-a-number")}, + {value: "not-a-number"}, + {value: true}, + } { + got, ok := todoExactInteger(tc.value) + if got != tc.want || ok != tc.ok { + t.Fatalf("todoExactInteger(%#v) = %d/%v, want %d/%v", tc.value, got, ok, tc.want, tc.ok) + } + } + if todoUpdateFieldMatches("unknown", 1, 1) { + t.Fatal("unknown update field matched") + } } func TestCrossPlatformCoverageTodoDoneBranches(t *testing.T) { diff --git a/internal/shortcut/types.go b/internal/shortcut/types.go index b3545f36..602baf39 100644 --- a/internal/shortcut/types.go +++ b/internal/shortcut/types.go @@ -195,6 +195,11 @@ type Shortcut struct { Tips []string // Hidden hides the command from listings while keeping it invocable. Hidden bool + // CompatibilityVisible preserves a historically visible CLI command while + // keeping it out of the Agent/public Shortcut catalog. Such a command is + // shown only by `dws shortcut list --all`; Availability independently says + // whether the historical execution path remains callable. + CompatibilityVisible bool // Disposition is the reviewed semantic relation to the Runtime Schema leaf // surface. It determines default Agent discovery independently from live // fixture evidence. diff --git a/internal/shortcut/usage/command.go b/internal/shortcut/usage/command.go index cd59dd51..f4677bcf 100644 --- a/internal/shortcut/usage/command.go +++ b/internal/shortcut/usage/command.go @@ -56,7 +56,7 @@ func newListCommand() *cobra.Command { if svc != "" && s.Service != svc { continue } - if s.Hidden && !includeHidden { + if (s.Hidden || s.CompatibilityVisible) && !includeHidden { continue } rows = append(rows, newShortcutListRow(s)) @@ -75,23 +75,24 @@ func newListCommand() *cobra.Command { } type shortcutListRow struct { - Service string `json:"service"` - Command string `json:"command"` - CLIPath string `json:"cli_path"` - Product string `json:"product"` - Risk string `json:"risk"` - Confirmation string `json:"confirmation"` - Description string `json:"description"` - Intent string `json:"intent,omitempty"` - Flags []shortcut.Flag `json:"flags"` - Constraints []shortcut.Constraint `json:"constraints"` - Examples []string `json:"examples"` - Public bool `json:"public"` - Disposition string `json:"disposition,omitempty"` - SemanticDelta string `json:"semantic_delta,omitempty"` - Availability string `json:"availability,omitempty"` - Primary string `json:"primary,omitempty"` - Reviewed bool `json:"reviewed"` + Service string `json:"service"` + Command string `json:"command"` + CLIPath string `json:"cli_path"` + Product string `json:"product"` + Risk string `json:"risk"` + Confirmation string `json:"confirmation"` + Description string `json:"description"` + Intent string `json:"intent,omitempty"` + Flags []shortcut.Flag `json:"flags"` + Constraints []shortcut.Constraint `json:"constraints"` + Examples []string `json:"examples"` + Public bool `json:"public"` + Disposition string `json:"disposition,omitempty"` + SemanticDelta string `json:"semantic_delta,omitempty"` + Availability string `json:"availability,omitempty"` + Primary string `json:"primary,omitempty"` + Reviewed bool `json:"reviewed"` + CompatibilityVisible bool `json:"compatibility_visible,omitempty"` } func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { @@ -120,23 +121,24 @@ func newShortcutListRow(s shortcut.Shortcut) shortcutListRow { constraints := append([]shortcut.Constraint{}, s.Constraints...) examples := append([]string{}, s.Tips...) return shortcutListRow{ - Service: s.Service, - Command: s.Command, - CLIPath: s.Service + " " + s.Command, - Product: product, - Risk: risk, - Confirmation: confirmation, - Description: s.Description, - Intent: s.Intent, - Flags: flags, - Constraints: constraints, - Examples: examples, - Public: !s.Hidden, - Disposition: string(s.Disposition), - SemanticDelta: s.SemanticDelta, - Availability: string(s.Availability), - Primary: s.PrimaryCommand, - Reviewed: s.SemanticReviewed, + Service: s.Service, + Command: s.Command, + CLIPath: s.Service + " " + s.Command, + Product: product, + Risk: risk, + Confirmation: confirmation, + Description: s.Description, + Intent: s.Intent, + Flags: flags, + Constraints: constraints, + Examples: examples, + Public: !s.Hidden && !s.CompatibilityVisible, + Disposition: string(s.Disposition), + SemanticDelta: s.SemanticDelta, + Availability: string(s.Availability), + Primary: s.PrimaryCommand, + Reviewed: s.SemanticReviewed, + CompatibilityVisible: s.CompatibilityVisible, } } diff --git a/internal/shortcut/usage/usage_test.go b/internal/shortcut/usage/usage_test.go index 3d1a103c..45040408 100644 --- a/internal/shortcut/usage/usage_test.go +++ b/internal/shortcut/usage/usage_test.go @@ -47,6 +47,11 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) Service: "coverage-usage", Command: "+hidden", }) + shortcut.Register(shortcut.Shortcut{ + Service: "coverage-usage", + Command: "+compatibility-visible", + CompatibilityVisible: true, + }) execute := func(args ...string) map[string]any { t.Helper() @@ -66,9 +71,20 @@ func TestCrossPlatformCoverageShortcutListFiltersHiddenAndService(t *testing.T) publicRows := execute("--service", "coverage-usage") allRows := execute("--service", "coverage-usage", "--all") - if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 1 { + if publicRows["count"].(float64) != 0 || allRows["count"].(float64) != 2 { t.Fatalf("hidden shortcuts were not filtered: public=%v all=%v", publicRows["count"], allRows["count"]) } + rows := allRows["shortcuts"].([]any) + foundCompatibilityVisible := false + for _, value := range rows { + row := value.(map[string]any) + if row["command"] == "+compatibility-visible" { + foundCompatibilityVisible = row["compatibility_visible"] == true && row["public"] == false + } + } + if !foundCompatibilityVisible { + t.Fatalf("compatibility-visible shortcut row lost its non-public marker: %#v", rows) + } missing := execute("--service", "__missing__") if missing["count"].(float64) != 0 { t.Fatalf("missing service returned shortcuts: %#v", missing) diff --git a/internal/shortcut/wiki/common.go b/internal/shortcut/wiki/common.go index ab57746d..94603853 100644 --- a/internal/shortcut/wiki/common.go +++ b/internal/shortcut/wiki/common.go @@ -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 { diff --git a/internal/shortcut/wiki/nodes.go b/internal/shortcut/wiki/nodes.go index aa893c87..7011e00a 100644 --- a/internal/shortcut/wiki/nodes.go +++ b/internal/shortcut/wiki/nodes.go @@ -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) diff --git a/internal/shortcut/wiki/workflow_coverage_test.go b/internal/shortcut/wiki/workflow_coverage_test.go index 457ceec9..b66490cc 100644 --- a/internal/shortcut/wiki/workflow_coverage_test.go +++ b/internal/shortcut/wiki/workflow_coverage_test.go @@ -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 { diff --git a/internal/upgrade/canonical_skills_test.go b/internal/upgrade/canonical_skills_test.go new file mode 100644 index 00000000..9b019a89 --- /dev/null +++ b/internal/upgrade/canonical_skills_test.go @@ -0,0 +1,521 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageCanonicalSkillLayoutMigratesUniversalCopiesAndLinksOtherAgents(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{ + ".agents/skills", ".codex/skills", ".claude/skills", ".openclaw/skills", + }) + + for _, parent := range []string{".codex", ".claude", ".openclaw"} { + if err := os.MkdirAll(filepath.Join(home, parent), 0o755); err != nil { + t.Fatal(err) + } + } + // beta.6 wrote a physical copy into Codex. The canonical migration must + // remove it without treating a matching user/market name as DWS-owned. + oldCodex := filepath.Join(home, ".codex", "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + + canonical := filepath.Join(home, ".agents", "skills") + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + if _, err := os.Stat(filepath.Join(canonical, name, "SKILL.md")); err != nil { + t.Fatalf("canonical %s missing: %v", name, err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", name)); !os.IsNotExist(err) { + t.Fatalf("universal Codex duplicate remains for %s: %v", name, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + link := filepath.Join(home, agent, "skills", name) + info, err := os.Lstat(link) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s link = %#v, %v", link, info, err) + } + if _, err := os.Stat(filepath.Join(link, "SKILL.md")); err != nil { + t.Fatalf("%s does not resolve canonical content: %v", link, err) + } + } + } + result, err = UpgradeSkillLocationsWithOptions(multiRoot, SkillUpgradeOptions{Version: "1.0.58-beta.6"}) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("idempotent upgrade = %#v, %v", result, err) + } + for _, agent := range []string{".claude", ".openclaw"} { + info, err := os.Lstat(filepath.Join(home, agent, "skills", "dingtalk-chat")) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("idempotent upgrade replaced %s link: %#v, %v", agent, info, err) + } + } +} + +func TestCrossPlatformCoverageCanonicalCustomRootsSymlinkedParentsAndLexicalVictims(t *testing.T) { + home := withFakeHome(t) + customCodex := filepath.Join(t.TempDir(), "codex") + customHermes := filepath.Join(t.TempDir(), "hermes") + externalClaude := filepath.Join(t.TempDir(), "claude") + for _, root := range []string{customCodex, customHermes, externalClaude, filepath.Join(home, ".clawdbot"), filepath.Join(home, ".codeium", "windsurf")} { + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(externalClaude, filepath.Join(home, ".claude")); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { + switch name { + case "CODEX_HOME": + return customCodex + case "HERMES_HOME": + return customHermes + default: + return "" + } + }) + + oldCodex := filepath.Join(customCodex, "skills", "dingtalk-chat") + if err := os.MkdirAll(oldCodex, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(oldCodex, "SKILL.md"), []byte("beta.6"), 0o644); err != nil { + t.Fatal(err) + } + claudeBase := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(claudeBase, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeBase, "dingtalk-chat"), []byte("unexpected file"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing-target", filepath.Join(claudeBase, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat", "dingtalk-shared") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + if _, err := os.Lstat(oldCodex); !os.IsNotExist(err) { + t.Fatalf("custom CODEX_HOME duplicate remains: %v", err) + } + for _, base := range []string{ + claudeBase, + filepath.Join(customHermes, "skills"), + filepath.Join(home, ".clawdbot", "skills"), + filepath.Join(home, ".codeium", "windsurf", "skills"), + } { + for _, name := range []string{"dingtalk-chat", "dingtalk-shared"} { + path := filepath.Join(base, name) + info, statErr := os.Lstat(path) + if statErr != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("expected canonical link %s: %#v, %v", path, info, statErr) + } + body, readErr := os.ReadFile(filepath.Join(path, "SKILL.md")) + if readErr != nil || !strings.Contains(string(body), name) { + t.Fatalf("link %s does not resolve: %q, %v", path, body, readErr) + } + } + } +} + +func TestCrossPlatformCoverageCanonicalSkillLinksFallBackToCopies(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("links unavailable") }) + + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-chat") + result, err := UpgradeSkillLocations(multiRoot) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("upgrade = %#v, %v", result, err) + } + dest := filepath.Join(home, ".claude", "skills", "dingtalk-chat") + info, err := os.Lstat(dest) + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + t.Fatalf("copy fallback = %#v, %v", info, err) + } + if _, err := os.Stat(filepath.Join(dest, "SKILL.md")); err != nil { + t.Fatalf("copy fallback content missing: %v", err) + } +} + +func TestCrossPlatformCoverageConfiguredRootsDetectShallowAndApplicationAgents(t *testing.T) { + // Keep the destination HOME synthetic: app-bundle detection is deliberately + // machine-scoped and must not depend on the selected installation HOME. + home := t.TempDir() + testseam.Swap(t, &upgradeGetenv, func(string) string { return "" }) + for _, dir := range []string{filepath.Join(home, ".config", "kimchi"), filepath.Join(home, ".tabnine")} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + } + sentinel := filepath.Join(home, "app-sentinel") + if err := os.MkdirAll(sentinel, 0o755); err != nil { + t.Fatal(err) + } + appInfo, err := os.Stat(sentinel) + if err != nil { + t.Fatal(err) + } + zcodeApp := filepath.Join(string(filepath.Separator), "Applications", "ZCode.app") + minimaxApp := filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app") + originalStat := upgradeStat + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + if path == zcodeApp || path == minimaxApp { + return appInfo, nil + } + return originalStat(path) + }) + want := map[string]bool{ + ".config/kimchi/harness/skills": false, + ".tabnine/agent/skills": false, + ".zcode/skills": false, + ".minimax/skills": false, + } + for _, root := range configuredSkillRoots(home) { + label := filepath.ToSlash(root.label) + if _, ok := want[label]; ok { + want[label] = skillRootDetectedBase(root) + } + } + for label, detected := range want { + if !detected { + t.Errorf("root %s was not detected", label) + } + } +} + +func TestCrossPlatformCoverageConfiguredAgentRootsMatchUpstreamAndCustomHomes(t *testing.T) { + home := t.TempDir() + custom := map[string]string{ + "AUTOHAND_HOME": filepath.Join(home, "custom-autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(home, "custom-claude"), + "CODEX_HOME": filepath.Join(home, "custom-codex"), + "GROK_HOME": filepath.Join(home, "custom-grok"), + "HERMES_HOME": filepath.Join(home, "custom-hermes"), + "VIBE_HOME": filepath.Join(home, "custom-vibe"), + "XDG_CONFIG_HOME": filepath.Join(home, "custom-xdg"), + } + testseam.Swap(t, &upgradeGetenv, func(name string) string { return custom[name] }) + roots := configuredSkillRoots(home) + if len(roots) != len(knownSkillDirs) { + t.Fatalf("configured unique roots = %d, want %d", len(roots), len(knownSkillDirs)) + } + byLabel := make(map[string]resolvedSkillRoot, len(roots)) + for _, root := range roots { + if _, duplicate := byLabel[root.label]; duplicate { + t.Fatalf("duplicate configured label %s", root.label) + } + byLabel[root.label] = root + } + wants := map[string]string{ + ".autohand/skills": filepath.Join(custom["AUTOHAND_HOME"], "skills"), + ".claude/skills": filepath.Join(custom["CLAUDE_CONFIG_DIR"], "skills"), + ".codex/skills": filepath.Join(custom["CODEX_HOME"], "skills"), + ".grok/skills": filepath.Join(custom["GROK_HOME"], "skills"), + ".hermes/skills": filepath.Join(custom["HERMES_HOME"], "skills"), + ".vibe/skills": filepath.Join(custom["VIBE_HOME"], "skills"), + ".config/agents/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "agents", "skills"), + ".config/crush/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "crush", "skills"), + ".config/devin/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "devin", "skills"), + ".config/goose/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "goose", "skills"), + ".config/kimchi/harness/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "kimchi", "harness", "skills"), + ".config/opencode/skills": filepath.Join(custom["XDG_CONFIG_HOME"], "opencode", "skills"), + } + for label, want := range wants { + if got := byLabel[label].base; filepath.Clean(got) != filepath.Clean(want) { + t.Errorf("configured %s = %q, want %q", label, got, want) + } + } + for _, label := range []string{ + ".config/agents/skills", ".gemini/antigravity/skills", ".gemini/antigravity-cli/skills", + ".codex/skills", ".cursor/skills", ".deepagents/agent/skills", ".firebender/skills", + ".gemini/skills", ".copilot/skills", ".config/opencode/skills", + } { + if !byLabel[label].universal { + t.Errorf("%s not classified universal", label) + } + } + if !byLabel[".agents/skills"].canonical || byLabel[".claude/skills"].universal { + t.Fatal("canonical/non-universal classification drift") + } +} + +func TestCrossPlatformCoverageUpgradeCanonicalFailureFailsFastUnconditionally(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + withFakeHome(t) + // Canonical-only configuration (no non-universal dependents): a failed + // canonical publish must still fail the whole upgrade. Universal agents + // read canonical directly, so silently succeeding here would leave the + // machine without any usable Skill while reporting success. + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills"}) + testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("canonical denied") }) + + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + } else { + src = writeMultiBundle(t, src, "dingtalk-chat") + } + result, err := UpgradeSkillLocations(src) + if err == nil || !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("canonical publish failure must propagate an error, got (%v)", err) + } + if result == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("result = %#v", result) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeOpenClawAliasPriority(t *testing.T) { + for _, tc := range []struct { + name string + dirs []string + want string + }{ + {name: "default", want: ".openclaw"}, + {name: "moltbot", dirs: []string{".moltbot"}, want: ".moltbot"}, + {name: "clawdbot-before-moltbot", dirs: []string{".moltbot", ".clawdbot"}, want: ".clawdbot"}, + {name: "openclaw-first", dirs: []string{".moltbot", ".clawdbot", ".openclaw"}, want: ".openclaw"}, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + for _, dir := range tc.dirs { + if err := os.MkdirAll(filepath.Join(home, dir), 0o755); err != nil { + t.Fatal(err) + } + } + if got := resolveOpenClawSkillBase(home); got != filepath.Join(home, tc.want, "skills") { + t.Fatalf("OpenClaw root = %q", got) + } + }) + } +} + +func TestCrossPlatformCoverageUpgradeLinkStagingFailureEdges(t *testing.T) { + home := t.TempDir() + canonical := filepath.Join(home, ".agents", "skills") + dest := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(filepath.Join(canonical, "dingtalk-chat"), 0o755); err != nil { + t.Fatal(err) + } + + t.Run("mkdir", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "创建 Agent Skill 目录") { + t.Fatalf("mkdir failure = %v", err) + } + }) + t.Run("temp", func(t *testing.T) { + testseam.Swap(t, &upgradeMkdirTemp, func(string, string) (string, error) { return "", errors.New("temp denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "链接 staging") { + t.Fatalf("temp failure = %v", err) + } + }) + t.Run("physical-parent", func(t *testing.T) { + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == dest { + return "", errors.New("physical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "物理目录") { + t.Fatalf("physical failure = %v", err) + } + }) + t.Run("canonical-target", func(t *testing.T) { + target := filepath.Join(canonical, "dingtalk-chat") + testseam.Swap(t, &upgradeEvalSymlinks, func(path string) (string, error) { + if path == target { + return "", errors.New("canonical denied") + } + return filepath.EvalSymlinks(path) + }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "解析 canonical") { + t.Fatalf("canonical failure = %v", err) + } + }) + t.Run("relative-path", func(t *testing.T) { + testseam.Swap(t, &upgradeRel, func(string, string) (string, error) { return "", errors.New("relative denied") }) + if _, _, err := stageLinkedSkillSet(dest, canonical, ".stage-", []string{"dingtalk-chat"}); err == nil || !strings.Contains(err.Error(), "相对链接") { + t.Fatalf("relative failure = %v", err) + } + }) +} + +func TestCrossPlatformCoverageUpgradeAgentBranchFailures(t *testing.T) { + for _, mode := range []string{"mono", "multi"} { + t.Run(mode, func(t *testing.T) { + makeSource := func(t *testing.T) string { + t.Helper() + src := t.TempDir() + if mode == "mono" { + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("mono"), 0o644); err != nil { + t.Fatal(err) + } + return src + } + return writeMultiBundle(t, src, "dingtalk-chat") + } + + t.Run("undetected-and-physical-alias", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".qoder/skills"}) + if err := os.MkdirAll(filepath.Join(home, ".qoder"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(home, ".agents", "skills"), filepath.Join(home, ".qoder", "skills")); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatal(err) + } + if len(result.Results) < 3 || result.Results[1].Status != SkillDirSkipped || result.Results[2].Status != SkillDirSkipped { + t.Fatalf("skip branches = %#v", result.Results) + } + }) + + t.Run("universal-retire-failure", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + if mode == "multi" { + useUpgradeManagedNames(t, "dingtalk-chat") + } + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { + if src == victim { + return "", errors.New("retire denied") + } + return origRename(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil { + t.Fatalf("retire failure must not fail the upgrade: %v", err) + } + if len(result.Failed()) != 0 { + t.Fatalf("retire failure must not count as an install failure: %#v", result.Failed()) + } + warnings := result.RetireWarnings() + if len(warnings) != 1 || warnings[0].Err == nil { + t.Fatalf("retire warnings = %#v", warnings) + } + if len(result.Succeeded()) == 0 { + t.Fatalf("canonical must still be published: %#v", result) + } + if _, statErr := os.Lstat(victim); statErr != nil { + t.Fatalf("unretired copy must be preserved: %v", statErr) + } + }) + + t.Run("universal-retire-success", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".cursor/skills"}) + victimBase := filepath.Join(home, ".cursor", "skills") + victim := filepath.Join(victimBase, "dws") + if mode == "multi" { + victim = filepath.Join(victimBase, "dingtalk-chat") + useUpgradeManagedNames(t, "dingtalk-chat") + } + if err := os.MkdirAll(victim, 0o755); err != nil { + t.Fatal(err) + } + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 0 { + t.Fatalf("retire success = %#v, %v", result, err) + } + if _, err := os.Lstat(victim); !os.IsNotExist(err) { + t.Fatalf("universal victim remains: %v", err) + } + }) + + t.Run("victim-scan-and-fallback-copy-failures", func(t *testing.T) { + t.Run("victim-scan", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + origReadDir := upgradeReadDir + testseam.Swap(t, &upgradeReadDir, func(path string) ([]os.DirEntry, error) { + if path == base { + return nil, errors.New("scan denied") + } + return origReadDir(path) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("victim scan = %#v, %v", result, err) + } + }) + + t.Run("fallback-copy", func(t *testing.T) { + home := withFakeHome(t) + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + base := filepath.Join(home, ".claude", "skills") + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &upgradeSymlink, func(string, string) error { return errors.New("link denied") }) + origCopy := upgradeCopyDir + testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { + if strings.HasPrefix(filepath.Clean(dst), filepath.Clean(base)+string(filepath.Separator)) { + return errors.New("copy denied") + } + return origCopy(src, dst) + }) + result, err := UpgradeSkillLocations(makeSource(t)) + if err != nil || len(result.Failed()) != 1 { + t.Fatalf("fallback copy = %#v, %v", result, err) + } + }) + }) + }) + } +} + +func TestCrossPlatformCoverageUpgradeWindowsPathNormalizationAndRootDedup(t *testing.T) { + testseam.Swap(t, &upgradeFoldPathCase, true) + if got := skillRootPathKey(filepath.Join("Root", "Skills")); got != strings.ToLower(got) { + t.Fatalf("case-folded path key = %q", got) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills", ".claude/skills"}) + if roots := configuredSkillRoots(t.TempDir()); len(roots) != 2 { + t.Fatalf("deduplicated roots = %#v", roots) + } +} diff --git a/internal/upgrade/coverage_edges_test.go b/internal/upgrade/coverage_edges_test.go index 960d81f7..061d7d68 100644 --- a/internal/upgrade/coverage_edges_test.go +++ b/internal/upgrade/coverage_edges_test.go @@ -264,7 +264,7 @@ func TestCrossPlatformCoverageUpgradePathsAndSkillsEdges(t *testing.T) { } knownSkillDirs = []string{".agents/skills", ".real/skills", ".missing/skills", ".present/skills"} result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Failed()) != 0 { + if err != nil || len(result.Succeeded()) != 2 || len(result.Failed()) != 0 { t.Fatalf("skill upgrade = %#v, %v", result, err) } knownSkillDirs = []string{".real/skills"} @@ -579,7 +579,10 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { }) home := t.TempDir() upgradeUserHomeDir = func() (string, error) { return home, nil } - knownSkillDirs = []string{".agents/skills"} + knownSkillDirs = []string{".agents/skills", ".claude/skills"} + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } calls := 0 upgradeCopyDir = func(src, dst string) error { calls++ @@ -593,8 +596,8 @@ func TestCrossPlatformCoverageUpgradePathInjectedFailures(t *testing.T) { t.Fatal(err) } result, err := UpgradeSkillLocations(source) - if err != nil || len(result.Succeeded()) != 1 || len(result.Results) != 1 { - t.Fatalf("replaced primary fallback = %#v, %v", result, err) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical failure = %#v, %v", result, err) } failure := errors.New("ensure failed") upgradeEnsureDir = func(string, os.FileMode) error { return failure } diff --git a/internal/upgrade/paths.go b/internal/upgrade/paths.go index a83309d3..3261cde1 100644 --- a/internal/upgrade/paths.go +++ b/internal/upgrade/paths.go @@ -8,6 +8,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "runtime" "sort" "strings" @@ -35,46 +36,133 @@ const ( // - test/scripts/package_script_test.go expectedPackagedSkillTargets // - scripts/release/verify-package-managers.sh HOME_AGENT_PARENTS / HOME_SKILL_TARGETS // -// The first entry (.agents/skills) is a generic fallback. It is used only -// when no concrete Agent home is detected; otherwise publishing there would -// duplicate every Skill for Agents (including Codex) that scan both roots. -// Subsequent entries are updated when their parent directory already exists. +// The first entry (.agents/skills) is the canonical global Skill directory, +// following the universal .agents/skills convention. +// Agents that understand the universal .agents convention read it directly; +// detected non-universal Agents receive relative links to this canonical +// copy (with a direct-copy fallback when links are unavailable). var knownSkillDirs = []string{ ".agents/skills", + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".copilot/skills", + ".config/opencode/skills", + ".aider-desk/skills", + ".astrbot/data/skills", + ".autohand/skills", + ".augment/skills", + ".bob/skills", ".claude/skills", - ".cursor/skills", + ".openclaw/skills", + ".codeartsdoer/skills", + ".codebuddy/skills", + ".codemaker/skills", + ".codestudio/skills", + ".commandcode/skills", + ".continue/skills", + ".snowflake/cortex/skills", + ".config/crush/skills", + ".config/devin/skills", + ".factory/skills", + ".forge/skills", + ".config/goose/skills", + ".grok/skills", + ".hermes/skills", + ".inferencesh/skills", + ".jazz/skills", + ".junie/skills", + ".iflow/skills", + ".kilocode/skills", + ".config/kimchi/harness/skills", + ".kiro/skills", + ".kode/skills", + ".lingma/skills", + ".mcpjam/skills", + ".minimax/skills", + ".vibe/skills", + ".moxby/skills", + ".mux/skills", + ".openhands/skills", + ".ona/skills", + ".pi/agent/skills", ".qoder/skills", + ".qoder-cn/skills", + ".qwen/skills", + ".reasonix/skills", + ".rovodev/skills", + ".roo/skills", + ".tabnine/agent/skills", + ".terramind/skills", + ".tinycloud/skills", + ".trae/skills", + ".trae-cn/skills", + ".codeium/windsurf/skills", + ".zcode/skills", + ".zencoder/skills", + ".neovate/skills", + ".pochi/skills", + ".adal/skills", + // DWS-only target retained for product compatibility. ".qoderwork/skills", + // beta.6 compatibility roots: cleanup only, never publish new copies. + ".cursor/skills", ".gemini/skills", ".codex/skills", - ".zcode/skills", ".github/skills", ".windsurf/skills", - ".augment/skills", ".cline/skills", ".amp/skills", - ".kiro/skills", - ".trae/skills", - ".openclaw/skills", - ".hermes/skills", +} + +// universalSkillDirs are concrete Agent directories whose current clients +// natively scan ~/.agents/skills. Publishing another copy or link below these +// roots makes the same Skill appear twice. Keep this classification aligned +// with vercel-labs/skills' isUniversalAgent rule. Qoderwork is intentionally +// non-universal because it is a DWS-specific integration absent upstream. +var universalSkillDirs = map[string]bool{ + ".config/agents/skills": true, + ".gemini/antigravity/skills": true, + ".gemini/antigravity-cli/skills": true, + ".codex/skills": true, + ".cursor/skills": true, + ".deepagents/agent/skills": true, + ".firebender/skills": true, + ".gemini/skills": true, + ".copilot/skills": true, + ".config/opencode/skills": true, + // beta.6 compatibility roots are cleanup-only as well. + ".github/skills": true, + ".windsurf/skills": true, + ".cline/skills": true, + ".amp/skills": true, } var ( - upgradeUserHomeDir = os.UserHomeDir - upgradeExecutable = os.Executable - upgradeEvalSymlinks = filepath.EvalSymlinks - upgradeCopyDir = copyDir - upgradeEnsureDir = ensureDir - upgradeRemoveAll = os.RemoveAll - upgradeMkdirAll = os.MkdirAll - upgradeMkdirTemp = os.MkdirTemp - upgradeReadDir = os.ReadDir - upgradeStat = os.Stat - upgradeBuildProvenance = skillprovenance.Build - upgradeReadSkillState = skillstate.Read - upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } - upgradeWriteSkillState = skillstate.Write - upgradeNow = time.Now + upgradeUserHomeDir = os.UserHomeDir + upgradeExecutable = os.Executable + upgradeEvalSymlinks = filepath.EvalSymlinks + upgradeCopyDir = copyDir + upgradeEnsureDir = ensureDir + upgradeRemoveAll = os.RemoveAll + upgradeMkdirAll = os.MkdirAll + upgradeMkdirTemp = os.MkdirTemp + upgradeReadDir = os.ReadDir + upgradeStat = os.Stat + upgradeLstat = os.Lstat + upgradeSymlink = os.Symlink + upgradeRel = filepath.Rel + upgradeGetenv = os.Getenv + upgradeBuildProvenance = skillprovenance.Build + upgradeReadSkillState = skillstate.Read + upgradePublishSkillPath = PublishSkillPathNoReplace + upgradeRollbackPublishedSkillPaths = RollbackSkillPathPublications + upgradeBackupStamp = func() string { return time.Now().UTC().Format("20060102-150405") } + upgradeWriteSkillState = skillstate.Write + upgradeNow = time.Now + upgradeFoldPathCase = runtime.GOOS == "windows" ) // skillBackupSubdir is the user-level directory where skill directories are @@ -85,43 +173,64 @@ const skillBackupSubdir = ".dws/skill-backups" // backupAndRemoveSkillDir moves dir into /.dws/skill-backups/ // / instead of destroying it, and returns the backup path. It is -// fail-safe: a directory that cannot be backed up is NOT removed and the +// fail-safe: a path that cannot be backed up is NOT removed and the // error is returned so the caller can surface it (and never install the -// opposite layout next to it silently). Missing paths and regular files are -// no-ops ("", nil). +// opposite layout next to it silently). Missing paths are no-ops. Directories, +// links (including dangling links), and ordinary files are all preserved: +// an unexpected file at a managed destination must never be overwritten. func backupAndRemoveSkillDir(homeDir, dir string) (string, error) { - info, err := upgradeStat(dir) + _, err := upgradeLstat(dir) if err != nil { if os.IsNotExist(err) { return "", nil } return "", fmt.Errorf("检查技能目录失败 %s: %w", dir, err) } - if !info.IsDir() { - return "", nil - } rel, relErr := filepath.Rel(homeDir, dir) if relErr != nil || rel == "." || strings.HasPrefix(rel, "..") { rel = filepath.Base(dir) } name := strings.NewReplacer(string(filepath.Separator), "-", "/", "-").Replace(rel) stamp := upgradeBackupStamp() - backupRoot := filepath.Join(homeDir, skillBackupSubdir, stamp) - target := filepath.Join(backupRoot, name) - for i := 1; ; i++ { - if _, err := os.Stat(target); os.IsNotExist(err) { - break + if err := upgradeMkdirAll(filepath.Join(homeDir, skillBackupSubdir), dirPermShared); err != nil { + return "", fmt.Errorf("创建备份根目录失败 %s: %w", filepath.Join(homeDir, skillBackupSubdir), err) + } + var backupRoot, target string + for i := 0; ; i++ { + candidate := stamp + if i > 0 { + candidate = fmt.Sprintf("%s-%d", stamp, i) } - backupRoot = filepath.Join(homeDir, skillBackupSubdir, fmt.Sprintf("%s-%d", stamp, i)) - target = filepath.Join(backupRoot, name) if i > 1000 { - return "", fmt.Errorf("备份目录冲突无法解决: %s", target) + return "", fmt.Errorf("备份目录冲突无法解决: %s", filepath.Join(homeDir, skillBackupSubdir, candidate)) } + backupRoot = filepath.Join(homeDir, skillBackupSubdir, candidate) + target = filepath.Join(backupRoot, name) + if _, err := upgradeLstat(target); err == nil { + continue // payload name already taken in this stamp root + } else if !os.IsNotExist(err) { + return "", fmt.Errorf("检查备份目标失败 %s: %w", target, err) + } + if err := skillPathMkdir(backupRoot, dirPermShared); err == nil { + // We created the root: stamp ownership before any payload moves + // in, so an interrupted backup never leaves an unmarked + // (never-prunable) stamp behind. + if err := skillBackupWriteMarker(backupRoot); err != nil { + // Bounded cleanup: the root is empty and ours alone. + _ = skillPathRemove(backupRoot) + return "", fmt.Errorf("写入备份所有权标记失败 %s: %w", backupRoot, err) + } + break + } else if !os.IsExist(err) { + return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) + } else if skillBackupMarkerValid(backupRoot) { + break // proven ours: same-stamp payloads from this run reuse it + } + // A stamp-shaped directory we cannot prove ours must never be + // adopted — writing the marker or the payload into it would turn + // its foreign contents into prunable DWS backups. } - if err := upgradeMkdirAll(backupRoot, dirPermShared); err != nil { - return "", fmt.Errorf("创建备份目录失败 %s: %w", backupRoot, err) - } - if err := upgradeRename(dir, target); err != nil { + if err := moveSkillPathRecoverably(dir, target); err != nil { return "", fmt.Errorf("备份技能目录失败 %s: %w", dir, err) } // Keep the backup directory bounded; a prune failure must not fail the @@ -151,13 +260,17 @@ const ( SkillDirSkipped // agent not detected, directory skipped SkillDirBlacklisted // blacklisted, never touched SkillDirFailed // installation attempted but failed + // SkillDirRetireWarning marks a universal Agent whose obsolete private copy + // could not be retired. Nothing is installed below such a root, so the + // leftover is reported without counting as an install failure. + SkillDirRetireWarning ) // SkillDirResult holds the per-directory install result. type SkillDirResult struct { Dir string // destination directory (e.g. ~/.claude/skills/dws) Status SkillDirStatus // outcome - Err error // non-nil when Status == SkillDirFailed + Err error // non-nil when Status == SkillDirFailed or SkillDirRetireWarning } // SkillUpgradeResult aggregates the outcome of an UpgradeSkillLocations call. @@ -187,6 +300,18 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { return out } +// RetireWarnings returns universal Agent roots that still hold an obsolete +// private copy because retiring it failed. +func (r *SkillUpgradeResult) RetireWarnings() []SkillDirResult { + var out []SkillDirResult + for _, d := range r.Results { + if d.Status == SkillDirRetireWarning { + out = append(out, d) + } + } + return out +} + // UpgradeSkillLocations refreshes skills from extractedDir into agent homes. // extractedDir may be a multi-skill bundle root (subdirectories each containing // SKILL.md) or a legacy mono root (SKILL.md at its top level). Callers that @@ -204,11 +329,13 @@ func (r *SkillUpgradeResult) Failed() []SkillDirResult { // `dws skill setup --mode mono` flow. // // Strategy (matches npm install.js installSkillsToHomes): -// - Concrete agent dirs (claude, cursor, codex, ...) are updated when their -// parent directory exists (e.g. ~/.codex/ exists => user has Codex) -// - ~/.agents/skills/ is used only when no concrete Agent is detected +// - ~/.agents/skills/ is always the canonical global store +// - universal Agents (Codex, Cursor, Gemini, Cline, Amp, Copilot) read the +// canonical store directly, so old agent-specific copies are retired +// - detected non-universal Agents receive relative links to canonical; +// filesystems that reject links receive a direct-copy fallback // - ~/.real/ and other blacklisted paths are NEVER touched -// - If no location was updated at all, fall back to ~/.agents/skills/ +// - canonical publication is mandatory and fails the upgrade loudly // // Opposite-mode leftovers are backed up to ~/.dws/skill-backups/ and then // removed so mono and multi never co-exist after an upgrade; a leftover that @@ -277,9 +404,54 @@ func buildUpgradeProvenanceRecords(root string, names []string, version string) // backups are kept. const skillBackupKeep = 5 +// The ownership marker inside a stamp root. Every DWS surface writes the +// exact same bytes (install.sh, install-skills.sh, install-event.sh, +// install-devapp.sh, the PowerShell installers, and build/npm/install.js): +// a stamp-shaped directory name alone is not ownership proof, because users +// and unrelated tools can create 20260819-120000-shaped names. Counting or +// deleting a backup root requires the marker to verify, and adopting an +// existing root for a new backup requires it first. +const ( + skillBackupMarkerName = ".dws-skill-backup" + skillBackupMarkerContent = "dws skill backup v1\n" +) + +var ( + skillBackupReadMarker = func(root string) (string, error) { + data, err := os.ReadFile(filepath.Join(root, skillBackupMarkerName)) + return string(data), err + } + skillBackupWriteMarker = func(root string) error { + return os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644) + } +) + +// skillBackupMarkerValid reports whether root carries the ownership marker +// with exactly the expected bytes; a missing, unreadable, or differently +// worded marker means the directory is foreign data. +func skillBackupMarkerValid(root string) bool { + body, err := skillBackupReadMarker(root) + return err == nil && body == skillBackupMarkerContent +} + +// skillBackupStampRegexp matches only directory names DWS itself writes: +// UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. Any other entry +// in the backup root is foreign (user data, unrelated tooling) and must be +// left untouched, so pruning is restricted to names it can prove DWS created. +var skillBackupStampRegexp = regexp.MustCompile(`^[0-9]{8}-[0-9]{6}(-[0-9]+)?$`) + +// isSkillBackupStamp reports whether name is a DWS-created backup stamp. +func isSkillBackupStamp(name string) bool { + return skillBackupStampRegexp.MatchString(name) +} + // pruneSkillBackups removes the oldest backup directories when more than -// skillBackupKeep remain. Best-effort: a removal failure never aborts, but -// pruning failures are reported so callers can warn the user. +// skillBackupKeep remain. Only directories whose names match the DWS backup +// stamp format AND whose ownership marker verifies are candidates; unknown +// or unmarked directories — including stamp roots created before the +// ownership marker existed — are foreign data and preserved. Best-effort: +// a removal failure never aborts, but pruning failures are reported so +// callers can warn the user. func pruneSkillBackups(homeDir string) error { root := filepath.Join(homeDir, skillBackupSubdir) entries, err := upgradeReadDir(root) @@ -291,7 +463,12 @@ func pruneSkillBackups(homeDir string) error { } names := make([]string, 0, len(entries)) for _, e := range entries { - if e.IsDir() { + if !e.IsDir() || !isSkillBackupStamp(e.Name()) { + continue + } + // A stamp-shaped name alone is not ownership proof: only roots + // whose marker verifies are counted or deleted. + if skillBackupMarkerValid(filepath.Join(root, e.Name())) { names = append(names, e.Name()) } } @@ -404,20 +581,15 @@ func uniqueSkillDirs(paths []string) []string { // restoreSkillSet removes any newly published directories, then restores all // original directories in reverse backup order. -func restoreSkillSet(published []string, backups []backedUpSkillDir) error { - var restoreErr error - for i := len(published) - 1; i >= 0; i-- { - if err := upgradeRemoveAll(published[i]); err != nil { - restoreErr = errors.Join(restoreErr, fmt.Errorf("移除失败发布目录 %s: %w", published[i], err)) - } - } +func restoreSkillSet(published []SkillPathPublication, backups []backedUpSkillDir) error { + restoreErr := upgradeRollbackPublishedSkillPaths(published) for i := len(backups) - 1; i >= 0; i-- { backup := backups[i] if err := upgradeMkdirAll(filepath.Dir(backup.original), dirPermShared); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("创建 Skill 恢复目录 %s: %w", filepath.Dir(backup.original), err)) continue } - if err := upgradeRename(backup.backup, backup.original); err != nil { + if err := moveSkillPathRecoverably(backup.backup, backup.original); err != nil { restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复原 Skill 失败 %s: %w", backup.original, err)) } } @@ -450,16 +622,17 @@ func publishStagedSkillSet(homeDir string, staged []stagedSkillDir, victims []st if err != nil { return err } - published := make([]string, 0, len(staged)) + published := make([]SkillPathPublication, 0, len(staged)) for _, skill := range staged { - if err := upgradeRename(skill.staged, skill.dest); err != nil { - publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, err) + publication, publishErr := upgradePublishSkillPath(skill.staged, skill.dest) + if publishErr != nil { + publishErr := fmt.Errorf("发布 Skill 失败 %s: %w", skill.dest, publishErr) if restoreErr := restoreSkillSet(published, backups); restoreErr != nil { return errors.Join(publishErr, fmt.Errorf("恢复原 Skill 集合失败: %w", restoreErr)) } return publishErr } - published = append(published, skill.dest) + published = append(published, publication) } return nil } @@ -517,100 +690,274 @@ func publishMultiUpgradeTarget(homeDir, destBase, multiRoot string, skills []str return publishStagedSkillSet(homeDir, staged, victims) } -func hasDetectedSpecificSkillRoot(homeDir string) bool { - for _, agentDir := range knownSkillDirs { - if isGenericSkillRoot(agentDir) || isBlacklisted(agentDir) { - continue - } - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if info, err := upgradeStat(parentGate); err == nil && info.IsDir() { - return true - } - } - return false -} - func isGenericSkillRoot(agentDir string) bool { return filepath.Clean(agentDir) == filepath.Clean(".agents/skills") } -func retireGenericSkillRoot(homeDir string, managed map[string]bool) error { - base := filepath.Join(homeDir, ".agents", "skills") +func isUniversalSkillRoot(agentDir string) bool { + return universalSkillDirs[filepath.ToSlash(filepath.Clean(agentDir))] +} + +type resolvedSkillRoot struct { + base string + label string + universal bool + canonical bool +} + +func resolveOpenClawSkillBase(homeDir string) string { + for _, name := range []string{".openclaw", ".clawdbot", ".moltbot"} { + base := filepath.Join(homeDir, name) + if info, err := upgradeStat(base); err == nil && info.IsDir() { + return filepath.Join(base, "skills") + } + } + return filepath.Join(homeDir, ".openclaw", "skills") +} + +func configuredSkillRoots(homeDir string) []resolvedSkillRoot { + var roots []resolvedSkillRoot + seen := map[string]bool{} + configHome := strings.TrimSpace(upgradeGetenv("XDG_CONFIG_HOME")) + if configHome == "" { + configHome = filepath.Join(homeDir, ".config") + } + add := func(base, label string, universal, canonical bool) { + key := skillRootPathKey(base) + if seen[key] { + return + } + seen[key] = true + roots = append(roots, resolvedSkillRoot{base: base, label: label, universal: universal, canonical: canonical}) + } + for _, agentDir := range knownSkillDirs { + base := filepath.Join(homeDir, agentDir) + switch filepath.ToSlash(filepath.Clean(agentDir)) { + case ".claude/skills": + if custom := strings.TrimSpace(upgradeGetenv("CLAUDE_CONFIG_DIR")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".codex/skills": + if custom := strings.TrimSpace(upgradeGetenv("CODEX_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".hermes/skills": + if custom := strings.TrimSpace(upgradeGetenv("HERMES_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".autohand/skills": + if custom := strings.TrimSpace(upgradeGetenv("AUTOHAND_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".grok/skills": + if custom := strings.TrimSpace(upgradeGetenv("GROK_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".vibe/skills": + if custom := strings.TrimSpace(upgradeGetenv("VIBE_HOME")); custom != "" { + base = filepath.Join(custom, "skills") + } + case ".openclaw/skills": + base = resolveOpenClawSkillBase(homeDir) + case ".config/agents/skills": + base = filepath.Join(configHome, "agents", "skills") + case ".config/opencode/skills": + base = filepath.Join(configHome, "opencode", "skills") + case ".config/crush/skills": + base = filepath.Join(configHome, "crush", "skills") + case ".config/devin/skills": + base = filepath.Join(configHome, "devin", "skills") + case ".config/goose/skills": + base = filepath.Join(configHome, "goose", "skills") + case ".config/kimchi/harness/skills": + base = filepath.Join(configHome, "kimchi", "harness", "skills") + } + add(base, agentDir, isUniversalSkillRoot(agentDir), isGenericSkillRoot(agentDir)) + } + return roots +} + +func skillRootDetectedBase(root resolvedSkillRoot) bool { + detectedDir := filepath.Dir(root.base) + switch filepath.ToSlash(filepath.Clean(root.label)) { + case ".config/kimchi/harness/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".tabnine/agent/skills": + detectedDir = filepath.Dir(filepath.Dir(root.base)) + case ".zcode/skills": + // Application bundles are machine-scoped detection signals. Keep this + // independent of HOME so upgrade matches npm, Shell, and PowerShell. + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "ZCode.app")); err == nil && info.IsDir() { + return true + } + case ".minimax/skills": + if info, err := upgradeStat(filepath.Join(string(filepath.Separator), "Applications", "MiniMax Code.app")); err == nil && info.IsDir() { + return true + } + } + info, err := upgradeStat(detectedDir) + return err == nil && info.IsDir() +} + +func samePhysicalSkillRoot(left, right string) bool { + leftReal, leftErr := upgradeEvalSymlinks(left) + rightReal, rightErr := upgradeEvalSymlinks(right) + if leftErr != nil || rightErr != nil { + return false + } + return skillRootPathKey(leftReal) == skillRootPathKey(rightReal) +} + +func skillRootPathKey(path string) string { + clean := filepath.Clean(path) + if upgradeFoldPathCase { + clean = strings.ToLower(clean) + } + return clean +} + +func retireManagedSkillRoot(homeDir, base string, managed map[string]bool) error { victims, err := managedMultiSkillVictims(base, managed) if err != nil { return err } victims = append(victims, filepath.Join(base, "dws")) if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("迁移通用 Skill 根目录失败: %w", err) + return fmt.Errorf("迁移 Agent Skill 旧副本失败: %w", err) } return nil } +func stageLinkedSkillSet(destBase, canonicalBase, prefix string, names []string) (stageRoot string, staged []stagedSkillDir, err error) { + if err := upgradeMkdirAll(destBase, dirPermShared); err != nil { + return "", nil, fmt.Errorf("创建 Agent Skill 目录失败 %s: %w", destBase, err) + } + stageRoot, err = upgradeMkdirTemp(destBase, prefix) + if err != nil { + return "", nil, fmt.Errorf("创建 Skill 链接 staging 失败 %s: %w", destBase, err) + } + defer func() { + if err != nil { + _ = upgradeRemoveAll(stageRoot) + } + }() + realDestBase, realErr := upgradeEvalSymlinks(destBase) + if realErr != nil { + return stageRoot, nil, fmt.Errorf("解析 Agent Skill 物理目录失败 %s: %w", destBase, realErr) + } + for _, name := range names { + target := filepath.Join(canonicalBase, name) + realTarget, targetErr := upgradeEvalSymlinks(target) + if targetErr != nil { + return stageRoot, nil, fmt.Errorf("解析 canonical Skill 失败 %s: %w", target, targetErr) + } + relTarget, relErr := upgradeRel(realDestBase, realTarget) + if relErr != nil { + return stageRoot, nil, fmt.Errorf("计算 Skill 相对链接失败 %s: %w", target, relErr) + } + stagedPath := filepath.Join(stageRoot, name) + if linkErr := upgradeSymlink(relTarget, stagedPath); linkErr != nil { + return stageRoot, nil, fmt.Errorf("创建 Skill 链接失败 %s -> %s: %w", stagedPath, relTarget, linkErr) + } + staged = append(staged, stagedSkillDir{staged: stagedPath, dest: filepath.Join(destBase, name)}) + } + return stageRoot, staged, nil +} + +func publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase string, names []string, victims []string) error { + needed := make([]string, 0, len(names)) + correct := make(map[string]bool, len(names)) + for _, name := range names { + dest := filepath.Join(destBase, name) + canonical := filepath.Join(canonicalBase, name) + if samePhysicalSkillRoot(dest, canonical) { + correct[filepath.Clean(dest)] = true + continue + } + needed = append(needed, name) + } + filteredVictims := make([]string, 0, len(victims)) + for _, victim := range victims { + if !correct[filepath.Clean(victim)] { + filteredVictims = append(filteredVictims, victim) + } + } + stageRoot, staged, err := stageLinkedSkillSet(destBase, canonicalBase, ".dws-link-set-", needed) + if err != nil { + return err + } + defer func() { _ = upgradeRemoveAll(stageRoot) }() + return publishStagedSkillSet(homeDir, staged, filteredVictims) +} + // upgradeMonoSkillLocations is the legacy mono behavior: one dws/ directory // per agent home. func upgradeMonoSkillLocations(homeDir, skillSrc string) (*SkillUpgradeResult, error) { result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + canonicalDest := filepath.Join(canonicalBase, "dws") + if err := publishMonoUpgradeTarget(homeDir, canonicalBase, skillSrc, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirFailed, Err: err}) + // Canonical is the mandatory store: universal agents read it directly, so a + // failed canonical publish means nobody received the upgrade. Always fail + // loud — including universal-only machines — instead of reporting success + // with nothing installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalDest, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - destDir := filepath.Join(homeDir, agentDir, "dws") - - if isBlacklisted(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { + continue + } + destBase := root.base + destDir := filepath.Join(destBase, "dws") + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetectedBase(root) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(filepath.Join(homeDir, agentDir)) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + continue + } + if root.universal { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirRetireWarning, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := monoUpgradeVictims(destBase, destDir, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, []string{"dws"}, victims); err != nil { + // An uncertain destination may belong to a concurrent writer. + // The copy fallback below would back that object aside and + // replace it — exactly what the sentinel forbids — so surface + // the uncertain state instead of retrying over it. + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) + continue + } + // Match the upstream installer: platforms/filesystems that reject + // links receive a direct copy rather than losing Agent support. + if copyErr := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMonoUpgradeTarget(homeDir, filepath.Join(homeDir, agentDir), skillSrc, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destDir, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location. The multi - // leftovers under the primary base are the usual reason the primary - // install failed, so clean them first — failing loud like the multi - // fallback — instead of letting mono and multi co-exist marked OK. - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - dest := filepath.Join(destBase, "dws") - if err := publishMonoUpgradeTarget(homeDir, destBase, skillSrc, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == dest { - result.Results[idx] = SkillDirResult{Dir: dest, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: dest, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level mono cache so that // `dws skill setup --mode mono` fallbacks stay on the upgraded version @@ -633,61 +980,62 @@ func upgradeMultiSkillLocations(homeDir, multiRoot string, skills []string) (*Sk result := &SkillUpgradeResult{} managedNames := readManagedSkillNames(homeDir) - hasSpecificRoot := hasDetectedSpecificSkillRoot(homeDir) + canonicalBase := filepath.Join(homeDir, ".agents", "skills") + if err := publishMultiUpgradeTarget(homeDir, canonicalBase, multiRoot, skills, skillSet, managedNames); err != nil { + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirFailed, Err: err}) + // Canonical is the mandatory store (see the mono branch): fail loud even + // on universal-only machines instead of reporting success with nothing + // installed. + return result, fmt.Errorf("canonical Skill 安装失败: %w", err) + } + result.Results = append(result.Results, SkillDirResult{Dir: canonicalBase, Status: SkillDirOK}) - for _, agentDir := range knownSkillDirs { - destBase := filepath.Join(homeDir, agentDir) - - if isBlacklisted(agentDir) { + for _, root := range configuredSkillRoots(homeDir) { + if root.canonical { + continue + } + destBase := root.base + if isBlacklisted(root.label) { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirBlacklisted}) continue } - if isGenericSkillRoot(agentDir) && hasSpecificRoot { + if !skillRootDetectedBase(root) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) continue } - - if !isGenericSkillRoot(agentDir) { - parentGate := filepath.Dir(destBase) - if _, err := upgradeStat(parentGate); os.IsNotExist(err) { + if samePhysicalSkillRoot(destBase, canonicalBase) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + continue + } + if root.universal { + if err := retireManagedSkillRoot(homeDir, destBase, managedNames); err != nil { + // Nothing is installed below a universal root, so a stale copy that + // resists retirement is a warning, not an install failure. + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirRetireWarning, Err: err}) + } else { result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirSkipped}) + } + continue + } + victims, victimErr := multiUpgradeVictims(destBase, skillSet, skills, managedNames) + if victimErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: victimErr}) + continue + } + if err := publishLinkedUpgradeTarget(homeDir, destBase, canonicalBase, skills, victims); err != nil { + // An uncertain destination may belong to a concurrent writer; + // the copy fallback must not displace it (see the mono branch). + if errors.Is(err, ErrSkillPathPublicationUncertain) { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) + continue + } + if copyErr := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); copyErr != nil { + result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: errors.Join(err, copyErr)}) continue } } - - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirFailed, Err: err}) - continue - } result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) } - if hasSpecificRoot && len(result.Succeeded()) > 0 { - genericBase := filepath.Join(homeDir, ".agents", "skills") - if err := retireGenericSkillRoot(homeDir, managedNames); err != nil { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirFailed, Err: err}) - } else { - result.Results = append(result.Results, SkillDirResult{Dir: genericBase, Status: SkillDirSkipped}) - } - } - - // Fallback: if nothing succeeded, force the primary location - if len(result.Succeeded()) == 0 && !hasSpecificRoot { - destBase := filepath.Join(homeDir, ".agents", "skills") - if err := publishMultiUpgradeTarget(homeDir, destBase, multiRoot, skills, skillSet, managedNames); err != nil { - return result, fmt.Errorf("所有技能目录安装失败,回退到主目录也失败: %w", err) - } - // Replace the earlier failed entry for this dir (if any) or append a new one - replaced := false - for idx, r := range result.Results { - if r.Dir == destBase { - result.Results[idx] = SkillDirResult{Dir: destBase, Status: SkillDirOK} - replaced = true - break - } - } - if !replaced { - result.Results = append(result.Results, SkillDirResult{Dir: destBase, Status: SkillDirOK}) - } - } // Best-effort: refresh the user-level caches so that `dws skill setup` // fallbacks stay on the upgraded version. The release zip ships both @@ -762,23 +1110,6 @@ func skillTreeHasRoot(dir string) bool { return err == nil && !info.IsDir() } -// cleanupMultiLeftovers backs up + removes every proven DWS-managed -// multi-mode skill directory inside one agent home before mono is installed. -// A missing base directory simply means no leftovers; any other read failure -// is reported so mono never silently co-exists with multi. Removal is -// reversible: each leftover is preserved under ~/.dws/skill-backups/ and a -// backup failure aborts the removal for that home. -func cleanupMultiLeftovers(homeDir, baseDir string) error { - victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理 multi 残留失败: %w", err) - } - return nil -} - func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]string, error) { entries, err := upgradeReadDir(baseDir) if err != nil { @@ -789,7 +1120,7 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str } victims := make([]string, 0) for _, e := range entries { - if !e.IsDir() || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || !isManagedMultiSkillDir(filepath.Join(baseDir, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(baseDir, e.Name())) @@ -797,24 +1128,6 @@ func managedMultiSkillVictims(baseDir string, managed ...map[string]bool) ([]str return victims, nil } -// cleanupOppositeModeLeftovers backs up + removes, inside one agent home, the -// legacy mono directory (dws/) and every proven DWS-managed multi skill -// directory that is not part of the new bundle. A dingtalk-* prefix alone is -// not proof of ownership because market/user skills may use the same prefix. -// Removal is reversible: each -// directory is preserved under ~/.dws/skill-backups/ and a backup failure -// aborts the removal for that home. -func cleanupOppositeModeLeftovers(homeDir, destBase string, skillSet map[string]bool) error { - victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) - if err != nil { - return err - } - if _, err := backupSkillSet(homeDir, victims); err != nil { - return fmt.Errorf("备份并清理对面模式残留失败: %w", err) - } - return nil -} - func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed ...map[string]bool) ([]string, error) { victims := []string{filepath.Join(destBase, "dws")} entries, err := upgradeReadDir(destBase) @@ -825,7 +1138,7 @@ func oppositeModeSkillVictims(destBase string, skillSet map[string]bool, managed return nil, fmt.Errorf("读取技能目录失败 %s: %w", destBase, err) } for _, e := range entries { - if !e.IsDir() || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { + if (!e.IsDir() && e.Type()&os.ModeSymlink == 0) || skillSet[e.Name()] || !isManagedMultiSkillDir(filepath.Join(destBase, e.Name()), managed...) { continue } victims = append(victims, filepath.Join(destBase, e.Name())) diff --git a/internal/upgrade/paths_multi_test.go b/internal/upgrade/paths_multi_test.go index ec55c21d..a6f2f7a9 100644 --- a/internal/upgrade/paths_multi_test.go +++ b/internal/upgrade/paths_multi_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "reflect" + "runtime" "strings" "testing" @@ -22,6 +23,16 @@ func withFakeHome(t *testing.T) string { t.Helper() home := t.TempDir() testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home, nil }) + // Host-installed macOS apps must not make a fake HOME appear to have extra + // detected Agents. Tests that exercise application-based detection can + // override this seam explicitly. + testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + applications := filepath.Join(string(filepath.Separator), "Applications") + if path == filepath.Join(applications, "ZCode.app") || path == filepath.Join(applications, "MiniMax Code.app") { + return nil, os.ErrNotExist + } + return os.Stat(path) + }) return home } @@ -124,8 +135,7 @@ func TestCrossPlatformCoverageBundleSkillNamesLayouts(t *testing.T) { func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { home := withFakeHome(t) - // A concrete Agent root wins over the generic .agents fallback; .claude - // installs and .cursor is skipped. + // .agents is canonical; detected Claude receives links and Cursor is skipped. agentsBase := filepath.Join(home, ".agents", "skills") claudeBase := filepath.Join(home, ".claude", "skills") for _, base := range []string{agentsBase, claudeBase} { @@ -193,17 +203,19 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { // Succeeded entries report the agent home base in multi mode. succeeded := result.Succeeded() - if len(succeeded) != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", len(succeeded), result.Results) + if len(succeeded) != 2 { + t.Fatalf("Succeeded() len = %d, want 2 (%v)", len(succeeded), result.Results) } - wantDirs := map[string]bool{claudeBase: true} + wantDirs := map[string]bool{agentsBase: true, claudeBase: true} for _, d := range succeeded { if !wantDirs[d.Dir] { t.Errorf("unexpected succeeded dir %q", d.Dir) } } - if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { - t.Fatalf("generic mono duplicate still visible: %v", err) + for _, name := range []string{"dingtalk-chat", "dws-shared"} { + if _, err := os.Stat(filepath.Join(agentsBase, name, "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } } // Multi cache refreshed under the fake home. @@ -216,7 +228,7 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMulti(t *testing.T) { } } -func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicate(t *testing.T) { +func TestCrossPlatformCoverageUpgradeUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { home := withFakeHome(t) testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".codex/skills"}) @@ -241,12 +253,12 @@ func TestCrossPlatformCoverageUpgradeUsesAgentSpecificRootWithoutGenericDuplicat t.Fatalf("UpgradeSkillLocationsWithOptions() = %#v, %v", result, err) } - want := filepath.Join(codexBase, "dingtalk-chat", "SKILL.md") + want := filepath.Join(genericBase, "dingtalk-chat", "SKILL.md") if _, err := os.Stat(want); err != nil { - t.Fatalf("canonical Codex Skill missing at %s: %v", want, err) + t.Fatalf("canonical Skill missing at %s: %v", want, err) } for _, duplicate := range []string{ - filepath.Join(genericBase, "dingtalk-chat", "SKILL.md"), + filepath.Join(codexBase, "dingtalk-chat"), filepath.Join(genericBase, "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -502,6 +514,9 @@ func TestUpgradeMonoCleansPreStateOfficialAndPreservesCustom(t *testing.T) { // mono-only package path). func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") if err := os.MkdirAll(filepath.Join(agentsBase, "dws"), 0o755); err != nil { t.Fatal(err) @@ -526,21 +541,15 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoFallbackAfterCopyFailure( } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("fallback should replace the failed entry with OK, got failed=%v", failed) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (%v)", got, result.Results) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical copy failure = %#v, %v", result, err) } data, err := os.ReadFile(filepath.Join(agentsBase, "dws", "SKILL.md")) if err != nil { t.Fatalf("mono fallback install missing: %v", err) } - if string(data) != "# mono" { - t.Errorf("mono content = %q, want refreshed package", data) + if string(data) != "old" { + t.Errorf("mono content = %q, want original preserved", data) } } @@ -574,22 +583,22 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * } result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v (fallback should not run: .claude succeeded)", err) + if err == nil { + t.Fatal("canonical read failure must fail the upgrade") } failed := result.Failed() if len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) } - wantDir := agentsBase + wantDir := filepath.Join(agentsBase, "dws") if failed[0].Dir != wantDir || failed[0].Err == nil { t.Fatalf("failed entry = %#v, want dir %q with non-nil err", failed[0], wantDir) } if !strings.Contains(failed[0].Err.Error(), "读取技能目录失败") { t.Fatalf("failed err should mention the read failure, got %v", failed[0].Err) } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) + if got := len(result.Succeeded()); got != 0 { + t.Fatalf("Succeeded() len = %d, want 0", got) } // Mono must NOT have been laid down next to the unreadable multi state. if _, err := os.Stat(filepath.Join(agentsBase, "dws")); !os.IsNotExist(err) { @@ -603,6 +612,9 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMonoReadDirErrorFailsHome(t * // installing multi next to the stale skills. func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t *testing.T) { home := withFakeHome(t) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } agentsBase := filepath.Join(home, ".agents", "skills") staleDir := filepath.Join(agentsBase, "dingtalk-stale") if err := os.MkdirAll(staleDir, 0o755); err != nil { @@ -613,10 +625,10 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } useUpgradeManagedNames(t, filepath.Base(staleDir)) - origRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + origRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, "dingtalk-stale") { - return errors.New("injected backup failure") + return "", errors.New("injected backup failure") } return origRename(src, dst) }) @@ -628,8 +640,8 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t if err == nil { t.Fatal("multi fallback cleanup failure must return an error") } - if !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("error should mention the fallback cleanup failure, got %v", err) + if !strings.Contains(err.Error(), "canonical Skill 安装失败") { + t.Fatalf("error should mention the canonical cleanup failure, got %v", err) } if failed := result.Failed(); len(failed) != 1 { t.Fatalf("Failed() len = %d, want 1 (%v)", len(failed), result.Results) @@ -643,8 +655,17 @@ func TestCrossPlatformCoverageUpgradeSkillLocationsMultiFallbackCleanupFailure(t } } +// seedSkillBackupMarker stamps a backup root as DWS-owned, the way every +// install surface does before any payload moves in. +func seedSkillBackupMarker(t *testing.T, root string) { + t.Helper() + if err := os.WriteFile(filepath.Join(root, skillBackupMarkerName), []byte(skillBackupMarkerContent), 0o644); err != nil { + t.Fatal(err) + } +} + // TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges pins the fail-safe -// contract of the backup helper: non-dir paths are no-ops, a colliding backup +// contract of the backup helper: all lexical paths are preserved, a colliding backup // target gets a numbered stamp, and any failure (mkdir / rename / unresolvable // collision) leaves the original directory untouched with an error. func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { @@ -654,8 +675,8 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { // backup. Inject it through the path seam so this branch is portable to // Windows, where chmod-based permission failures are not reliable. statFailurePath := filepath.Join(home, "stat-failure") - origStat := upgradeStat - testseam.Swap(t, &upgradeStat, func(path string) (os.FileInfo, error) { + origStat := upgradeLstat + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { if path == statFailurePath { return nil, errors.New("injected stat failure") } @@ -665,16 +686,16 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("stat failure = (%q, %v), want wrapped error", got, err) } - // Regular file: no-op, no backup. + // Regular file: preserve it instead of allowing a later rename to overwrite it. filePath := filepath.Join(home, "not-a-dir") if err := os.WriteFile(filePath, []byte("x"), 0o644); err != nil { t.Fatal(err) } - if got, err := backupAndRemoveSkillDir(home, filePath); got != "" || err != nil { - t.Fatalf("regular file = (%q, %v), want no-op", got, err) + if got, err := backupAndRemoveSkillDir(home, filePath); got == "" || err != nil { + t.Fatalf("regular file = (%q, %v), want backup", got, err) } - if _, err := os.Stat(filePath); err != nil { - t.Fatalf("regular file must survive: %v", err) + if _, err := os.Stat(filePath); !os.IsNotExist(err) { + t.Fatalf("regular file must move into backup: %v", err) } // A skill directory outside home falls back to its basename instead of @@ -722,6 +743,46 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatalf("victim must be gone after backup, stat err=%v", err) } + // A dangling link at the first backup target is still an occupied lexical + // path and must select a numbered target rather than being overwritten. + danglingVictim := filepath.Join(home, ".agents", "skills", "dangling-collision") + if err := os.MkdirAll(danglingVictim, 0o755); err != nil { + t.Fatal(err) + } + danglingTaken := filepath.Join(home, skillBackupSubdir, "20260810-000000", ".agents-skills-dangling-collision") + if err := os.Symlink("missing-backup", danglingTaken); err == nil { + got, err = backupAndRemoveSkillDir(home, danglingVictim) + if err != nil { + t.Fatalf("backup with dangling collision error = %v", err) + } + want = filepath.Join(home, skillBackupSubdir, "20260810-000000-1", ".agents-skills-dangling-collision") + if got != want { + t.Fatalf("dangling collision backup path = %q, want %q", got, want) + } + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + // An unreadable target probe is not equivalent to a free path. + probeVictim := filepath.Join(home, "probe-victim") + if err := os.MkdirAll(probeVictim, 0o755); err != nil { + t.Fatal(err) + } + probeTarget := filepath.Join(home, skillBackupSubdir, "20260810-000000", "probe-victim") + testseam.Swap(t, &upgradeLstat, func(path string) (os.FileInfo, error) { + if path == probeTarget { + return nil, errors.New("target probe denied") + } + return os.Lstat(path) + }) + if _, err := backupAndRemoveSkillDir(home, probeVictim); err == nil || !strings.Contains(err.Error(), "检查备份目标失败") { + t.Fatalf("target probe error = %v", err) + } + if _, err := os.Stat(probeVictim); err != nil { + t.Fatalf("probe victim must survive target check failure: %v", err) + } + testseam.Swap(t, &upgradeLstat, os.Lstat) + // Unresolvable collision (>1000 numbered stamps taken) fails and keeps dir. victim2 := filepath.Join(home, "victim2") if err := os.MkdirAll(victim2, 0o755); err != nil { @@ -749,7 +810,7 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { t.Fatal(err) } testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return errors.New("mkdir denied") }) - if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份目录失败") { + if _, err := backupAndRemoveSkillDir(home, victim3); err == nil || !strings.Contains(err.Error(), "创建备份根目录失败") { t.Fatalf("mkdir error = %v", err) } if _, err := os.Stat(victim3); err != nil { @@ -762,14 +823,14 @@ func TestCrossPlatformCoverageBackupAndRemoveSkillDirEdges(t *testing.T) { if err := os.MkdirAll(victim4, 0o755); err != nil { t.Fatal(err) } - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("rename denied") }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("rename denied") }) if _, err := backupAndRemoveSkillDir(home, victim4); err == nil || !strings.Contains(err.Error(), "备份技能目录失败") { t.Fatalf("rename error = %v", err) } if _, err := os.Stat(victim4); err != nil { t.Fatalf("victim4 must survive rename failure: %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) } // TestCrossPlatformCoveragePruneSkillBackupsEdges pins the backup retention: @@ -779,9 +840,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { home := t.TempDir() root := filepath.Join(home, skillBackupSubdir) for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260810-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } if err := pruneSkillBackups(home); err != nil { t.Fatalf("pruneSkillBackups() error = %v", err) @@ -814,9 +877,11 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { // Removal failure is reported as the first error. Seed more than // skillBackupKeep dirs again so the prune loop actually runs. for i := 0; i < skillBackupKeep+2; i++ { - if err := os.MkdirAll(filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)), 0o755); err != nil { + stampDir := filepath.Join(root, fmt.Sprintf("20260811-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { t.Fatal(err) } + seedSkillBackupMarker(t, stampDir) } testseam.Swap(t, &upgradeRemoveAll, func(string) error { return errors.New("remove denied") }) if err := pruneSkillBackups(home); err == nil { @@ -825,6 +890,86 @@ func TestCrossPlatformCoveragePruneSkillBackupsEdges(t *testing.T) { testseam.Swap(t, &upgradeRemoveAll, os.RemoveAll) } +// TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown pins that +// pruneSkillBackups only retires directories whose names match the DWS backup +// stamp format. Any foreign directory in the backup root must survive even when +// more than skillBackupKeep stamped backups exist. +func TestCrossPlatformCoveragePruneSkillBackupsPreservesUnknown(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, skillBackupSubdir) + for i := 0; i < skillBackupKeep+2; i++ { + stampDir := filepath.Join(root, fmt.Sprintf("20260910-00000%d", i)) + if err := os.MkdirAll(stampDir, 0o755); err != nil { + t.Fatal(err) + } + seedSkillBackupMarker(t, stampDir) + } + // A collision-suffixed stamp is still a valid DWS backup and is eligible. + suffixed := filepath.Join(root, "20260910-000000-7") + if err := os.MkdirAll(suffixed, 0o755); err != nil { + t.Fatal(err) + } + seedSkillBackupMarker(t, suffixed) + // Stamp-shaped names without a verifying marker are foreign data too: + // the name format alone proves nothing. + unmarked := filepath.Join(root, "20260901-000000") + if err := os.MkdirAll(unmarked, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(unmarked, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + wrongMarker := filepath.Join(root, "20260902-000000") + if err := os.MkdirAll(wrongMarker, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wrongMarker, skillBackupMarkerName), []byte("not dws"), 0o644); err != nil { + t.Fatal(err) + } + unknown := []string{ + "user-personal-backup", + "20260910-000000-abc", // stamp-shaped but non-numeric suffix + "20260910-00000", // too short + "notes", + "20260901-000000", // stamp-shaped but unmarked + "20260902-000000", // stamp-shaped but wrong marker content + } + for _, name := range unknown { + if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { + t.Fatal(err) + } + } + + if err := pruneSkillBackups(home); err != nil { + t.Fatalf("pruneSkillBackups() error = %v", err) + } + + for _, name := range unknown { + if _, err := os.Stat(filepath.Join(root, name)); err != nil { + t.Errorf("unknown backup entry %s must be preserved, stat err=%v", name, err) + } + } + // Only marker-proven backups beyond skillBackupKeep are pruned; foreign + // entries (including unmarked stamp-shaped ones) are excluded from the + // count, so the proven total stays at skillBackupKeep. + entries, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + proven := 0 + for _, e := range entries { + if isSkillBackupStamp(e.Name()) && skillBackupMarkerValid(filepath.Join(root, e.Name())) { + proven++ + } + } + if proven != skillBackupKeep { + t.Errorf("marker-proven backups after prune = %d, want %d", proven, skillBackupKeep) + } + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "must survive" { + t.Errorf("unmarked stamp-shaped data must survive, got %q, %v", data, err) + } +} + // TestCrossPlatformCoverageResolveSkillSrcLayouts pins every fallback branch of // the mono-source resolver and the multi-bundle child lookup. func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { @@ -888,88 +1033,27 @@ func TestCrossPlatformCoverageResolveSkillSrcLayouts(t *testing.T) { // TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges pins the mono // path's fail-loud backup semantics and every primary-fallback outcome. func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - mono := t.TempDir() if err := os.WriteFile(filepath.Join(mono, "SKILL.md"), []byte("# mono"), 0o644); err != nil { t.Fatal(err) } - - // Per-home backup failure marks that home failed; another home still wins. home := withFakeHome(t) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) os.MkdirAll(filepath.Join(home, ".agents", "skills", "dws"), 0o755) os.WriteFile(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md"), []byte("old"), 0o644) os.MkdirAll(filepath.Join(home, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, ".agents") { - return errors.New("backup denied") + return "", errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) result, err := UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) - } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want exactly 1 backup-failed home", failed) + if err == nil || len(result.Failed()) != 1 || len(result.Succeeded()) != 0 { + t.Fatalf("canonical backup failure = %#v, %v", result, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("failed home must keep its original dws/: %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 (.claude)", got) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback cleanup failure: only a blacklisted home in the main loop, then - // the primary cleanup hits a stale dir whose backup fails. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".real/skills"} - stale := filepath.Join(home2, ".agents", "skills", "dingtalk-stale") - os.MkdirAll(stale, 0o755) - useUpgradeManagedNames(t, filepath.Base(stale)) - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback cleanup error = %v", err) - } - if _, err := os.Stat(stale); err != nil { - t.Fatalf("stale dir must survive failed cleanup: %v", err) - } - - // Fallback backup failure of the primary dws/ itself. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".agents", "skills", "dws"), 0o755) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure: everything fails loud. - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(mono); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append (no prior entry for the primary dir): a blacklisted-only - // main loop leaves no primary entry, so the fallback appends a fresh OK. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - result, err = UpgradeSkillLocations(mono) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary", got) - } - if _, err := os.Stat(filepath.Join(home5, ".agents", "skills", "dws", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) + t.Fatalf("failed canonical home must keep its original dws/: %v", err) } } @@ -977,15 +1061,10 @@ func TestCrossPlatformCoverageMonoUpgradeBackupAndFallbackEdges(t *testing.T) { // path's blacklisted branch, per-skill backup/copy failures, and the primary // fallback outcomes (cleanup OK + backup/copy failure, fresh append). func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { - originalDirs := append([]string(nil), knownSkillDirs...) - t.Cleanup(func() { knownSkillDirs = originalDirs }) - extract := t.TempDir() multiRoot := writeMultiBundle(t, extract, "dingtalk-chat") - - // Blacklisted entry is reported, non-blacklisted installs. home := withFakeHome(t) - knownSkillDirs = []string{".real/skills", ".agents/skills"} + testseam.Swap(t, &knownSkillDirs, []string{".real/skills", ".agents/skills"}) os.MkdirAll(filepath.Join(home, ".agents"), 0o755) result, err := UpgradeSkillLocations(multiRoot) if err != nil { @@ -1003,119 +1082,33 @@ func TestCrossPlatformCoverageMultiUpgradeBackupAndFallbackEdges(t *testing.T) { if _, err := os.Stat(filepath.Join(home, ".real")); !os.IsNotExist(err) { t.Fatalf("blacklisted home must never be touched, stat err=%v", err) } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("canonical install missing: %v", err) + } +} - // A detected concrete Agent root wins over .agents. A failure while retiring - // the old generic copy is surfaced after the concrete root succeeds. - home2 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home2, nil }) - knownSkillDirs = []string{".agents/skills", ".claude/skills"} - os.MkdirAll(filepath.Join(home2, ".agents", "skills", "dingtalk-chat"), 0o755) - os.MkdirAll(filepath.Join(home2, ".claude"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, ".agents") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) +// The production wrappers that used to bundle these two steps were removed as +// dead code; the test keeps exercising the live building blocks they composed. +func cleanupMultiLeftoversForTest(homeDir, baseDir string) error { + victims, err := managedMultiSkillVictims(baseDir, readManagedSkillNames(homeDir)) if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) + return err } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 backup-failed home", failed) + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理 multi 残留失败: %w", err) } - if _, err := os.Stat(filepath.Join(home2, ".agents", "skills", "dingtalk-chat")); err != nil { - t.Fatalf("failed home must keep its original skill: %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) + return nil +} - // Per-skill copy failure on the concrete home fails that home; with no - // successful concrete target, the generic copy is left untouched. - home3 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home3, nil }) - os.MkdirAll(filepath.Join(home3, ".claude"), 0o755) - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - if strings.Contains(dst, ".claude") { - return errors.New("copy denied") - } - return copyDir(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) +func cleanupOppositeModeLeftoversForTest(homeDir, destBase string, skillSet map[string]bool) error { + victims, err := oppositeModeSkillVictims(destBase, skillSet, readManagedSkillNames(homeDir)) if err != nil { - t.Fatalf("UpgradeSkillLocations() error = %v", err) + return err } - if failed := result.Failed(); len(failed) != 1 || failed[0].Err == nil { - t.Fatalf("failed = %v, want 1 copy-failed home", failed) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback backup failure: the same-name bundle skill refresh is what - // fails (cleanup succeeds, the per-skill backup does not). - home4 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home4, nil }) - knownSkillDirs = []string{".real/skills"} - os.MkdirAll(filepath.Join(home4, ".agents", "skills", "dingtalk-chat"), 0o755) - testseam.Swap(t, &upgradeRename, func(src, dst string) error { - if strings.Contains(src, "dingtalk-chat") { - return errors.New("backup denied") - } - return os.Rename(src, dst) - }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback backup error = %v", err) - } - testseam.Swap(t, &upgradeRename, os.Rename) - - // Fallback copy failure. - home5 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home5, nil }) - testseam.Swap(t, &upgradeCopyDir, func(string, string) error { return errors.New("copy denied") }) - if _, err := UpgradeSkillLocations(multiRoot); err == nil || !strings.Contains(err.Error(), "回退到主目录也失败") { - t.Fatalf("fallback copy error = %v", err) - } - testseam.Swap(t, &upgradeCopyDir, copyDir) - - // Fallback append: blacklisted-only main loop → fresh OK entry appended. - home6 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home6, nil }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback append error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 appended primary (%v)", got, result.Results) - } - if _, err := os.Stat(filepath.Join(home6, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback append install missing: %v", err) - } - - // Fallback replace: the main loop fails the primary home on a transient - // copy error, the fallback retry succeeds and replaces the failed entry. - home7 := t.TempDir() - testseam.Swap(t, &upgradeUserHomeDir, func() (string, error) { return home7, nil }) - knownSkillDirs = []string{".agents/skills"} - origCopy := upgradeCopyDir - attempts := 0 - testseam.Swap(t, &upgradeCopyDir, func(src, dst string) error { - attempts++ - if attempts == 1 { - return errors.New("transient copy failure") - } - return origCopy(src, dst) - }) - result, err = UpgradeSkillLocations(multiRoot) - if err != nil { - t.Fatalf("fallback replace error = %v", err) - } - if got := len(result.Succeeded()); got != 1 { - t.Fatalf("Succeeded() len = %d, want 1 replaced primary (%v)", got, result.Results) - } - if failed := result.Failed(); len(failed) != 0 { - t.Fatalf("failed entry must be replaced by OK, got %v", failed) - } - if _, err := os.Stat(filepath.Join(home7, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("fallback replace install missing: %v", err) + if _, err := backupSkillSet(homeDir, victims); err != nil { + return fmt.Errorf("备份并清理对面模式残留失败: %w", err) } + return nil } // TestCrossPlatformCoverageCleanupLeftoversEdges pins the cleanup helpers: @@ -1127,40 +1120,40 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { // Read failure (non-ENOENT) surfaces from both cleanups. testseam.Swap(t, &upgradeReadDir, func(string) ([]os.DirEntry, error) { return nil, errors.New("read denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupMultiLeftovers read error = %v", err) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupMultiLeftoversForTest read error = %v", err) } - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { - t.Fatalf("cleanupOppositeModeLeftovers read error = %v", err) + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "读取技能目录失败") { + t.Fatalf("cleanupOppositeModeLeftoversForTest read error = %v", err) } testseam.Swap(t, &upgradeReadDir, os.ReadDir) // Backup failure of a multi leftover aborts cleanupMultiLeftovers. os.MkdirAll(filepath.Join(base, "dingtalk-stale"), 0o755) useUpgradeManagedNames(t, "dingtalk-stale") - testseam.Swap(t, &upgradeRename, func(string, string) error { return errors.New("backup denied") }) - if err := cleanupMultiLeftovers(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", errors.New("backup denied") }) + if err := cleanupMultiLeftoversForTest(home, base); err == nil || !strings.Contains(err.Error(), "备份并清理 multi 残留失败") { t.Fatalf("cleanupMultiLeftovers backup error = %v", err) } // Backup failure of the mono leftover aborts the opposite-mode cleanup. os.MkdirAll(filepath.Join(base, "dws"), 0o755) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup mono backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Backup failure of a stale (non-mono) skill aborts with its own message. - testseam.Swap(t, &upgradeRename, func(src, dst string) error { + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dst string) (string, error) { if strings.Contains(src, "dingtalk-stale") { - return errors.New("backup denied") + return "", errors.New("backup denied") } - return os.Rename(src, dst) + return renameSkillPathNoReplace(src, dst) }) - if err := cleanupOppositeModeLeftovers(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { + if err := cleanupOppositeModeLeftoversForTest(home, base, map[string]bool{}); err == nil || !strings.Contains(err.Error(), "备份并清理对面模式残留失败") { t.Fatalf("opposite cleanup stale backup error = %v", err) } - testseam.Swap(t, &upgradeRename, os.Rename) + testseam.Swap(t, &skillPathRenameNoReplace, renameSkillPathNoReplace) // Success matrix: mono leftover + stale skill removed into backups, bundle // skill and regular file preserved. @@ -1168,7 +1161,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { skillSet := map[string]bool{"dingtalk-keep": true} os.MkdirAll(filepath.Join(base, "dingtalk-keep"), 0o755) os.WriteFile(filepath.Join(base, "regular-file"), []byte("x"), 0o644) - if err := cleanupOppositeModeLeftovers(home, base, skillSet); err != nil { + if err := cleanupOppositeModeLeftoversForTest(home, base, skillSet); err != nil { t.Fatalf("cleanupOppositeModeLeftovers() error = %v", err) } for _, gone := range []string{"dws", "dingtalk-stale"} { @@ -1188,7 +1181,7 @@ func TestCrossPlatformCoverageCleanupLeftoversEdges(t *testing.T) { } // cleanupMultiLeftovers on a missing base is a no-op. - if err := cleanupMultiLeftovers(home, filepath.Join(home, "missing")); err != nil { + if err := cleanupMultiLeftoversForTest(home, filepath.Join(home, "missing")); err != nil { t.Fatalf("missing base must be a no-op, got %v", err) } } diff --git a/internal/upgrade/skill_backup_ownership_test.go b/internal/upgrade/skill_backup_ownership_test.go new file mode 100644 index 00000000..a9098806 --- /dev/null +++ b/internal/upgrade/skill_backup_ownership_test.go @@ -0,0 +1,113 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageSkillBackupOwnership pins the backup ownership +// contract: a stamp-shaped directory name alone proves nothing, so a backup +// root this transaction writes into is either created fresh (mkdir claim, +// marker stamped before any payload moves in) or an existing root whose +// marker already verifies. A same-stamp foreign directory is never adopted — +// writing the marker or the payload into it would turn its contents into +// prunable DWS backups. +func TestCrossPlatformCoverageSkillBackupOwnership(t *testing.T) { + + t.Run("unproven stamp root is never adopted", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + foreign := filepath.Join(home, skillBackupSubdir, "20260101-120000") + if err := os.MkdirAll(foreign, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(foreign, "user-data.txt"), []byte("must survive"), 0o644); err != nil { + t.Fatal(err) + } + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + backup, err := backupAndRemoveSkillDir(home, victim) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(home, skillBackupSubdir, "20260101-120000-1", "skills-dws"); backup != want { + t.Fatalf("backup = %s, want suffixed %s", backup, want) + } + assertUpgradeSkillContent(t, backup, "payload") + // The foreign root keeps its data and never receives our marker. + if data, readErr := os.ReadFile(filepath.Join(foreign, "user-data.txt")); readErr != nil || string(data) != "must survive" { + t.Fatalf("foreign data = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(foreign, skillBackupMarkerName)); !os.IsNotExist(statErr) { + t.Fatalf("foreign root must not receive the ownership marker, stat err=%v", statErr) + } + if !skillBackupMarkerValid(filepath.Dir(backup)) { + t.Fatal("the freshly claimed root must carry the ownership marker") + } + }) + + t.Run("same-stamp payloads reuse the proven root", func(t *testing.T) { + home := t.TempDir() + testseam.Swap(t, &upgradeBackupStamp, func() string { return "20260101-120000" }) + first := filepath.Join(home, "skills", "dws") + second := filepath.Join(home, "agents", "claude", "dws") + seedUpgradeSkill(t, first, "first", false) + seedUpgradeSkill(t, second, "second", false) + backupA, err := backupAndRemoveSkillDir(home, first) + if err != nil { + t.Fatal(err) + } + backupB, err := backupAndRemoveSkillDir(home, second) + if err != nil { + t.Fatal(err) + } + if filepath.Dir(backupA) != filepath.Dir(backupB) { + t.Fatalf("same-stamp backups split roots: %s vs %s", backupA, backupB) + } + root := filepath.Dir(backupA) + if !skillBackupMarkerValid(root) { + t.Fatal("reused root must keep a verifying marker") + } + assertUpgradeSkillContent(t, backupA, "first") + assertUpgradeSkillContent(t, backupB, "second") + }) + + t.Run("marker write failure refuses the backup and cleans the empty root", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillBackupWriteMarker, func(string) error { return errors.New("marker denied") }) + _, err := backupAndRemoveSkillDir(home, victim) + if err == nil || !strings.Contains(err.Error(), "备份所有权标记失败") { + t.Fatalf("marker write failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + entries, readErr := os.ReadDir(filepath.Join(home, skillBackupSubdir)) + if readErr != nil { + t.Fatal(readErr) + } + if len(entries) != 0 { + t.Fatalf("empty unowned stamp root must be cleaned, left %v", entries) + } + }) + + t.Run("backup root creation failure surfaces", func(t *testing.T) { + home := t.TempDir() + victim := filepath.Join(home, "skills", "dws") + seedUpgradeSkill(t, victim, "payload", false) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := backupAndRemoveSkillDir(home, victim) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("root creation failure = %v", err) + } + assertUpgradeSkillContent(t, victim, "payload") + }) +} diff --git a/internal/upgrade/skill_path_move.go b/internal/upgrade/skill_path_move.go new file mode 100644 index 00000000..3f41e6c2 --- /dev/null +++ b/internal/upgrade/skill_path_move.go @@ -0,0 +1,363 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "sort" +) + +var ( + skillPathRename = func(src, dst string) error { return upgradeRename(src, dst) } + skillPathRenameNoReplace = renameSkillPathNoReplace + skillPathCopy = copySkillPathLexically + skillPathVerify = verifySkillPathCopy + skillPathRemoveAll = os.RemoveAll + skillPathMkdirAll = os.MkdirAll + skillPathMkdirTemp = os.MkdirTemp + skillPathChmod = os.Chmod + skillPathLstat = os.Lstat + skillPathMkdir = os.Mkdir + skillPathLink = os.Link + skillPathRemove = os.Remove + skillPathReadDir = os.ReadDir + skillPathFileIdentity = skillPathFileIdentityImpl + skillPathFileIncarnation = skillPathFileIncarnationImpl + skillPathSameFileIdentity = skillPathSameFileIdentityImpl + skillPathReadlink = os.Readlink + skillPathSymlink = os.Symlink + skillPathOpen = os.Open + skillPathOpenFile = os.OpenFile + skillPathCopyBytes = io.Copy + skillPathSync = func(file *os.File) error { return file.Sync() } + skillPathWalkDir = filepath.WalkDir +) + +// moveSkillPathRecoverably moves one managed Skill path without weakening the +// backup contract when source and destination are on different filesystems. +// The cross-device path publishes a fully copied and verified sibling staging +// path before removing the source. Any failure before source removal leaves the +// source intact; a removal failure deliberately leaves both verified copies. +func moveSkillPathRecoverably(src, dst string) (err error) { + if _, statErr := skillPathLstat(dst); statErr == nil { + return fmt.Errorf("目标已存在: %s", dst) + } else if !os.IsNotExist(statErr) { + return fmt.Errorf("检查移动目标失败 %s: %w", dst, statErr) + } + if err := skillPathMkdirAll(filepath.Dir(dst), dirPermShared); err != nil { + return fmt.Errorf("创建移动目标目录失败 %s: %w", filepath.Dir(dst), err) + } + // moveSkillPathRecoverably is not a publication path; ignore any + // claim identity returned by the rename — no ownership check follows + // on this destination. + if _, renameErr := skillPathRenameNoReplace(src, dst); renameErr == nil { + return nil + } else if !isCrossDeviceError(renameErr) { + return fmt.Errorf("移动 Skill 路径失败 %s -> %s: %w", src, dst, renameErr) + } + + stageRoot, err := skillPathMkdirTemp(filepath.Dir(dst), "."+filepath.Base(dst)+".cross-device-") + if err != nil { + return fmt.Errorf("创建跨设备 Skill staging 失败 %s: %w", dst, err) + } + stage := filepath.Join(stageRoot, "payload") + stageCleaned := false + defer func() { + if stageCleaned { + return + } + makeSkillPathTreeWritable(stageRoot) + if cleanupErr := skillPathRemoveAll(stageRoot); cleanupErr != nil { + err = errors.Join(err, fmt.Errorf("清理跨设备 Skill staging 失败 %s: %w", stageRoot, cleanupErr)) + } + }() + + if err := skillPathCopy(src, stage); err != nil { + return fmt.Errorf("复制跨设备 Skill staging 失败 %s: %w", stage, err) + } + if err := skillPathVerify(src, stage); err != nil { + return fmt.Errorf("校验跨设备 Skill staging 失败 %s: %w", stage, err) + } + stageInfo, err := skillPathLstat(stage) + if err != nil { + return fmt.Errorf("检查跨设备 Skill staging 失败 %s: %w", stage, err) + } + if stageInfo.IsDir() { + // Darwin refuses to rename a read-only directory even when both parent + // directories are writable. The exact source mode was verified above; + // temporarily add owner access for publication, then restore it before + // the final verification and before the source is removed. + if err := skillPathChmod(stage, stageInfo.Mode().Perm()|0o700); err != nil { + return fmt.Errorf("准备跨设备 Skill staging 发布失败 %s: %w", stage, err) + } + } + // Cross-device staging publishes via a self-owned staging directory, + // so the claim identity is not consulted; discard it. + if _, err := skillPathRenameNoReplace(stage, dst); err != nil { + return fmt.Errorf("发布跨设备 Skill 备份失败 %s: %w", dst, err) + } + if stageInfo.IsDir() { + if err := skillPathChmod(dst, stageInfo.Mode().Perm()); err != nil { + return fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", dst, err) + } + } + if err := skillPathVerify(src, dst); err != nil { + return fmt.Errorf("校验已发布 Skill 备份失败 %s: %w", dst, err) + } + if err := skillPathRemoveAll(stageRoot); err != nil { + return fmt.Errorf("清理已发布 Skill staging 失败 %s: %w", stageRoot, err) + } + stageCleaned = true + if err := removePublishedSkillSource(src); err != nil { + return fmt.Errorf("Skill 目标已发布但源路径删除失败(源 %s 与目标 %s 均保留): %w", src, dst, err) + } + return nil +} + +type skillPathDirMode struct { + path string + mode os.FileMode +} + +func removePublishedSkillSource(src string) error { + dirModes, err := prepareSkillPathTreeRemoval(src) + if err != nil { + return err + } + removeErr := skillPathRemoveAll(src) + if removeErr == nil { + if _, statErr := skillPathLstat(src); os.IsNotExist(statErr) { + return nil + } else if statErr == nil { + removeErr = errors.New("源路径仍存在") + } else { + removeErr = fmt.Errorf("无法确认源路径已删除: %w", statErr) + } + } + return errors.Join(removeErr, restoreSkillPathDirModes(dirModes)) +} + +func prepareSkillPathTreeRemoval(root string) ([]skillPathDirMode, error) { + var modes []skillPathDirMode + err := skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if !entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + mode := info.Mode().Perm() + if mode&0o700 == 0o700 { + return nil + } + if err := skillPathChmod(path, mode|0o700); err != nil { + return err + } + modes = append(modes, skillPathDirMode{path: path, mode: mode}) + return nil + }) + if err != nil { + return nil, errors.Join(err, restoreSkillPathDirModes(modes)) + } + return modes, nil +} + +func restoreSkillPathDirModes(modes []skillPathDirMode) error { + var restoreErr error + for i := len(modes) - 1; i >= 0; i-- { + item := modes[i] + if _, err := skillPathLstat(item.path); os.IsNotExist(err) { + continue + } else if err != nil { + restoreErr = errors.Join(restoreErr, err) + continue + } + if err := skillPathChmod(item.path, item.mode); err != nil { + restoreErr = errors.Join(restoreErr, fmt.Errorf("恢复源目录权限失败 %s: %w", item.path, err)) + } + } + return restoreErr +} + +func makeSkillPathTreeWritable(root string) { + // Best effort only: RemoveAll below remains the source of truth and its + // error is returned. This preparation lets it traverse read-only staging + // directories left by a copy or verification failure. + _ = skillPathWalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr == nil && entry.IsDir() { + _ = skillPathChmod(path, 0o700) + } + return nil + }) +} + +// RestoreSkillPath restores a previously recorded backup using the same +// rename-first, verified cross-device fallback as backup publication. +func RestoreSkillPath(backup, original string) error { + return moveSkillPathRecoverably(backup, original) +} + +func copySkillPathLexically(src, dst string) error { + info, err := skillPathLstat(src) + if err != nil { + return err + } + switch { + case info.IsDir(): + // Staging must remain writable while children are copied. Restore the + // source mode only after the directory is complete so read-only Skill + // directories (for example 0555) can still be migrated lexically. + if err := skillPathMkdir(dst, 0o700); err != nil { + return err + } + entries, err := skillPathReadDir(src) + if err != nil { + return err + } + for _, entry := range entries { + if err := copySkillPathLexically(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil { + return err + } + } + return os.Chmod(dst, info.Mode().Perm()) + case info.Mode()&os.ModeSymlink != 0: + target, err := skillPathReadlink(src) + if err != nil { + return err + } + return skillPathSymlink(target, dst) + case info.Mode().IsRegular(): + return copyRegularSkillFile(src, dst, info.Mode().Perm()) + default: + return fmt.Errorf("不支持复制特殊 Skill 路径 %s(mode=%s)", src, info.Mode()) + } +} + +func copyRegularSkillFile(src, dst string, mode os.FileMode) (err error) { + in, err := skillPathOpen(src) + if err != nil { + return err + } + defer func() { err = errors.Join(err, in.Close()) }() + out, err := skillPathOpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + return err + } + defer func() { err = errors.Join(err, out.Close()) }() + if _, err := skillPathCopyBytes(out, in); err != nil { + return err + } + if err := skillPathSync(out); err != nil { + return err + } + return os.Chmod(dst, mode) +} + +func verifySkillPathCopy(src, dst string) error { + srcInfo, err := skillPathLstat(src) + if err != nil { + return err + } + dstInfo, err := skillPathLstat(dst) + if err != nil { + return err + } + if srcInfo.Mode()&os.ModeType != dstInfo.Mode()&os.ModeType { + return fmt.Errorf("路径类型不一致: %s (%s) != %s (%s)", src, srcInfo.Mode(), dst, dstInfo.Mode()) + } + switch { + case srcInfo.IsDir(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("目录权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + srcNames, err := skillPathEntryNames(src) + if err != nil { + return err + } + dstNames, err := skillPathEntryNames(dst) + if err != nil { + return err + } + if len(srcNames) != len(dstNames) { + return fmt.Errorf("目录项数量不一致: %s (%d) != %s (%d)", src, len(srcNames), dst, len(dstNames)) + } + for i, name := range srcNames { + if name != dstNames[i] { + return fmt.Errorf("目录项不一致: %s != %s", name, dstNames[i]) + } + if err := verifySkillPathCopy(filepath.Join(src, name), filepath.Join(dst, name)); err != nil { + return err + } + } + return nil + case srcInfo.Mode()&os.ModeSymlink != 0: + srcTarget, err := skillPathReadlink(src) + if err != nil { + return err + } + dstTarget, err := skillPathReadlink(dst) + if err != nil { + return err + } + if srcTarget != dstTarget { + return fmt.Errorf("符号链接目标不一致: %q != %q", srcTarget, dstTarget) + } + return nil + case srcInfo.Mode().IsRegular(): + if srcInfo.Mode().Perm() != dstInfo.Mode().Perm() { + return fmt.Errorf("文件权限不一致: %s (%s) != %s (%s)", src, srcInfo.Mode().Perm(), dst, dstInfo.Mode().Perm()) + } + if srcInfo.Size() != dstInfo.Size() { + return fmt.Errorf("文件大小不一致: %s (%d) != %s (%d)", src, srcInfo.Size(), dst, dstInfo.Size()) + } + srcDigest, err := skillPathFileDigest(src) + if err != nil { + return err + } + dstDigest, err := skillPathFileDigest(dst) + if err != nil { + return err + } + if srcDigest != dstDigest { + return fmt.Errorf("文件内容摘要不一致: %s != %s", src, dst) + } + return nil + default: + return fmt.Errorf("不支持校验特殊 Skill 路径 %s(mode=%s)", src, srcInfo.Mode()) + } +} + +func skillPathEntryNames(dir string) ([]string, error) { + entries, err := skillPathReadDir(dir) + if err != nil { + return nil, err + } + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + sort.Strings(names) + return names, nil +} + +func skillPathFileDigest(path string) ([sha256.Size]byte, error) { + f, err := skillPathOpen(path) + if err != nil { + return [sha256.Size]byte{}, err + } + defer f.Close() + hash := sha256.New() + if _, err := skillPathCopyBytes(hash, f); err != nil { + return [sha256.Size]byte{}, err + } + var digest [sha256.Size]byte + copy(digest[:], hash.Sum(nil)) + return digest, nil +} diff --git a/internal/upgrade/skill_path_move_errors_test.go b/internal/upgrade/skill_path_move_errors_test.go new file mode 100644 index 00000000..b9d00850 --- /dev/null +++ b/internal/upgrade/skill_path_move_errors_test.go @@ -0,0 +1,529 @@ +package upgrade + +import ( + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathMoveSystemErrors(t *testing.T) { + t.Run("destination probe", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == dst { + return nil, os.ErrPermission + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination parent", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + testseam.Swap(t, &skillPathMkdirAll, func(string, os.FileMode) error { return os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("staging creation", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", os.ErrPermission }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("failed staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return errors.New("copy failed") }) + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return errors.New("cleanup failed") }) + err := moveSkillPathRecoverably(src, dst) + if err == nil || !strings.Contains(err.Error(), "copy failed") || !strings.Contains(err.Error(), "cleanup failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing copied staging", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + testseam.Swap(t, &skillPathCopy, func(string, string) error { return nil }) + testseam.Swap(t, &skillPathVerify, func(string, string) error { return nil }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "检查跨设备") { + t.Fatalf("error = %v", err) + } + }) + + for _, tc := range []struct { + name string + path func(src, dst string) string + want string + }{ + {name: "prepare staging mode", path: func(_, dst string) string { return filepath.Join(filepath.Dir(dst), "payload") }, want: "准备跨设备"}, + {name: "restore published mode", path: func(_, dst string) string { return dst }, want: "恢复已发布"}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if tc.name == "prepare staging mode" && strings.HasSuffix(path, string(filepath.Separator)+"payload") { + return errors.New("chmod failed") + } + if tc.name == "restore published mode" && path == dst { + return errors.New("chmod failed") + } + return original(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("error = %v", err) + } + }) + } + + t.Run("published staging cleanup", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + original := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".cross-device-") { + return errors.New("cleanup failed") + } + return original(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "清理已发布") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("source deletion cannot be confirmed", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + removalAttempted := false + originalLstat := skillPathLstat + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + removalAttempted = true + return nil + } + return originalRemove(path) + }) + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src && removalAttempted { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathPermissionRestorationErrors(t *testing.T) { + t.Run("source preparation error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(string, fs.WalkDirFunc) error { + return errors.New("prepare failed") + }) + if err := removePublishedSkillSource("ignored"); err == nil || !strings.Contains(err.Error(), "prepare failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("walk error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, nil, errors.New("walk failed")) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "walk failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("writable mode preparation", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + var chmodMode os.FileMode + testseam.Swap(t, &skillPathChmod, func(_ string, mode os.FileMode) error { + chmodMode = mode + return nil + }) + modes, err := prepareSkillPathTreeRemoval("source") + if err != nil { + t.Fatal(err) + } + if chmodMode != 0o700 || len(modes) != 1 || modes[0].path != "source" || modes[0].mode != 0o500 { + t.Fatalf("prepared mode = %v, records = %#v", chmodMode, modes) + } + }) + + t.Run("writable mode preparation chmod error", func(t *testing.T) { + entry := skillPathModeDirEntry{mode: os.ModeDir | 0o500} + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, entry, nil) + }) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { + return errors.New("chmod failed") + }) + if _, err := prepareSkillPathTreeRemoval("source"); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry info error", func(t *testing.T) { + testseam.Swap(t, &skillPathWalkDir, func(root string, fn fs.WalkDirFunc) error { + return fn(root, skillPathErrorDirEntry{}, nil) + }) + if _, err := prepareSkillPathTreeRemoval("ignored"); err == nil || !strings.Contains(err.Error(), "info failed") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("missing restored directory", func(t *testing.T) { + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: filepath.Join(t.TempDir(), "missing"), mode: 0o555}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("restore probe error", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: "blocked", mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("restore chmod error", func(t *testing.T) { + path := t.TempDir() + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + if err := restoreSkillPathDirModes([]skillPathDirMode{{path: path, mode: 0o555}}); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathLexicalCopyErrors(t *testing.T) { + t.Run("source lstat", func(t *testing.T) { + if err := copySkillPathLexically(filepath.Join(t.TempDir(), "missing"), "unused"); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("destination mkdir", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copySkillPathLexically(src, dst); err == nil { + t.Fatal("expected mkdir failure") + } + }) + + t.Run("source readdir", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "src") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("nested copy", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + original := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "child" { + return nil, os.ErrPermission + } + return original(path) + }) + if err := copySkillPathLexically(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("readlink", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "link") + if err := os.Symlink("target", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if err := copySkillPathLexically(src, filepath.Join(root, "dst")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("special path", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := copySkillPathLexically("special", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular open", func(t *testing.T) { + if err := copyRegularSkillFile(filepath.Join(t.TempDir(), "missing"), "dst", 0o600); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("regular destination open", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("occupied"), 0o600); err != nil { + t.Fatal(err) + } + if err := copyRegularSkillFile(src, dst, 0o600); err == nil { + t.Fatal("expected exclusive create failure") + } + }) + + for _, tc := range []struct { + name string + swap func(*testing.T) + }{ + {name: "copy", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("copy failed") }) + }}, + {name: "sync", swap: func(t *testing.T) { + testseam.Swap(t, &skillPathSync, func(*os.File) error { return errors.New("sync failed") }) + }}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + tc.swap(t) + if err := copyRegularSkillFile(src, dst, 0o600); err == nil || !strings.Contains(err.Error(), tc.name) { + t.Fatalf("error = %v", err) + } + }) + } +} + +func TestCrossPlatformCoverageSkillPathVerificationErrors(t *testing.T) { + root := t.TempDir() + + t.Run("source and destination lstat", func(t *testing.T) { + if err := verifySkillPathCopy(filepath.Join(root, "missing-src"), "dst"); !os.IsNotExist(err) { + t.Fatalf("source error = %v", err) + } + src := filepath.Join(root, "source") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, filepath.Join(root, "missing-dst")); !os.IsNotExist(err) { + t.Fatalf("destination error = %v", err) + } + }) + + t.Run("directory and file modes", func(t *testing.T) { + for _, tc := range []struct { + name string + src os.FileMode + dst os.FileMode + }{ + {name: "directory", src: os.ModeDir | 0o700, dst: os.ModeDir | 0o755}, + {name: "file", src: 0o600, dst: 0o644}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := "src", "dst" + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == src { + return skillPathFakeInfo{mode: tc.src}, nil + } + return skillPathFakeInfo{mode: tc.dst}, nil + }) + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "权限") { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("directory read errors", func(t *testing.T) { + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("nested mismatch", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "child"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(dst, "child"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("symlink reads", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.Symlink("one", src); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if err := os.Symlink("one", dst); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathReadlink + testseam.Swap(t, &skillPathReadlink, func(path string) (string, error) { + if (path == dst) == failDest { + return "", os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("file digest opens", func(t *testing.T) { + base := t.TempDir() + src, dst := filepath.Join(base, "src"), filepath.Join(base, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + for _, failDest := range []bool{false, true} { + t.Run(map[bool]string{false: "source", true: "destination"}[failDest], func(t *testing.T) { + original := skillPathOpen + testseam.Swap(t, &skillPathOpen, func(path string) (*os.File, error) { + if (path == dst) == failDest { + return nil, os.ErrPermission + } + return original(path) + }) + if err := verifySkillPathCopy(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + }) + } + }) + + t.Run("special path", func(t *testing.T) { + fake := skillPathFakeInfo{mode: os.ModeNamedPipe} + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return fake, nil }) + if err := verifySkillPathCopy("src", "dst"); err == nil || !strings.Contains(err.Error(), "特殊") { + t.Fatalf("error = %v", err) + } + }) + + t.Run("entry names", func(t *testing.T) { + if _, err := skillPathEntryNames(filepath.Join(root, "missing-dir")); !os.IsNotExist(err) { + t.Fatalf("error = %v", err) + } + }) + + t.Run("digest open and read", func(t *testing.T) { + if _, err := skillPathFileDigest(filepath.Join(root, "missing-file")); !os.IsNotExist(err) { + t.Fatalf("open error = %v", err) + } + file := filepath.Join(root, "digest-file") + if err := os.WriteFile(file, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathCopyBytes, func(io.Writer, io.Reader) (int64, error) { return 0, errors.New("read failed") }) + if _, err := skillPathFileDigest(file); err == nil || !strings.Contains(err.Error(), "read failed") { + t.Fatalf("read error = %v", err) + } + }) +} + +type skillPathFakeInfo struct { + mode os.FileMode +} + +func (skillPathFakeInfo) Name() string { return "fake" } +func (skillPathFakeInfo) Size() int64 { return 0 } +func (info skillPathFakeInfo) Mode() os.FileMode { return info.mode } +func (skillPathFakeInfo) ModTime() time.Time { return time.Time{} } +func (info skillPathFakeInfo) IsDir() bool { return info.mode.IsDir() } +func (skillPathFakeInfo) Sys() any { return nil } + +type skillPathErrorDirEntry struct{} + +func (skillPathErrorDirEntry) Name() string { return "broken" } +func (skillPathErrorDirEntry) IsDir() bool { return true } +func (skillPathErrorDirEntry) Type() os.FileMode { return os.ModeDir } +func (skillPathErrorDirEntry) Info() (os.FileInfo, error) { return nil, errors.New("info failed") } + +type skillPathModeDirEntry struct { + mode os.FileMode +} + +func (skillPathModeDirEntry) Name() string { return "directory" } +func (skillPathModeDirEntry) IsDir() bool { return true } +func (entry skillPathModeDirEntry) Type() os.FileMode { return entry.mode.Type() } +func (entry skillPathModeDirEntry) Info() (os.FileInfo, error) { + return skillPathFakeInfo{mode: entry.mode}, nil +} diff --git a/internal/upgrade/skill_path_move_test.go b/internal/upgrade/skill_path_move_test.go new file mode 100644 index 00000000..1963a257 --- /dev/null +++ b/internal/upgrade/skill_path_move_test.go @@ -0,0 +1,451 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPathCrossDeviceMove(t *testing.T) { + t.Run("directory file and lexical links", func(t *testing.T) { + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(filepath.Join(src, "references"), 0o750); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("chat\n"), 0o640); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "references", "guide.md"), []byte("guide\n"), 0o600); err != nil { + t.Fatal(err) + } + linksCreated := true + if err := os.Symlink("SKILL.md", filepath.Join(src, "skill-link")); err != nil { + if runtime.GOOS != "windows" { + t.Fatal(err) + } + linksCreated = false + } + if linksCreated { + if err := os.Symlink("missing.md", filepath.Join(src, "dangling-link")); err != nil { + t.Fatal(err) + } + } + + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "chat\n" { + t.Fatalf("copied file = %q, %v", got, err) + } + if info, err := os.Stat(filepath.Join(dst, "SKILL.md")); err != nil { + t.Fatalf("copied file stat = %v, %v", info, err) + } else if runtime.GOOS != "windows" && info.Mode().Perm() != 0o640 { + t.Fatalf("copied file mode = %v; want 0640", info.Mode().Perm()) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(dst, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(dst, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("regular file root", func(t *testing.T) { + root := t.TempDir() + src := filepath.Join(root, "source-file") + dst := filepath.Join(root, "backup", "source-file") + if err := os.WriteFile(src, []byte("state"), 0o600); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(dst); err != nil || string(got) != "state" { + t.Fatalf("backup file = %q, %v", got, err) + } + }) + + t.Run("read-only directory mode is restored after copying children", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "backup", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("read only\n"), 0o444); err != nil { + t.Fatal(err) + } + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if info, err := os.Stat(dst); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("copied directory mode = %v, %v", info, err) + } + if got, err := os.ReadFile(filepath.Join(dst, "SKILL.md")); err != nil || string(got) != "read only\n" { + t.Fatalf("copied read-only file = %q, %v", got, err) + } + }) + + t.Run("rollback uses the same cross-device contract", func(t *testing.T) { + root := t.TempDir() + backup := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + original := filepath.Join(root, "external", "claude", "skills", "dingtalk-chat") + if err := os.MkdirAll(backup, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(backup, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, backup, original) + if err := RestoreSkillPath(backup, original); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(original, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(backup); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed after verified restore: %v", err) + } + }) +} + +func TestCrossPlatformCoverageSkillPathCrossDeviceFailures(t *testing.T) { + t.Run("non cross-device rename never copies", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + copied := false + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", os.ErrPermission }) + testseam.Swap(t, &skillPathCopy, func(string, string) error { copied = true; return nil }) + if err := moveSkillPathRecoverably(src, dst); !errors.Is(err, os.ErrPermission) { + t.Fatalf("error = %v", err) + } + if copied { + t.Fatal("permission failure must not enter cross-device copy fallback") + } + assertSkillSourcePreserved(t, src) + }) + + for _, tc := range []struct { + name string + copy func(string, string) error + verify func(string, string) error + }{ + {name: "copy failure", copy: func(string, string) error { return errors.New("copy failed") }}, + {name: "verification failure", verify: func(string, string) error { return errors.New("verify failed") }}, + } { + t.Run(tc.name, func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + if tc.copy != nil { + testseam.Swap(t, &skillPathCopy, tc.copy) + } + if tc.verify != nil { + testseam.Swap(t, &skillPathVerify, tc.verify) + } + if err := moveSkillPathRecoverably(src, dst); err == nil { + t.Fatal("expected failure") + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); !os.IsNotExist(err) { + t.Fatalf("formal backup must not publish: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + } + + t.Run("staging publish failure", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { + if from == src && to == dst { + return "", testCrossDeviceError() + } + if to == dst { + return "", errors.New("publish failed") + } + return originalRename(from, to) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "publish failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("published verification failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + calls := 0 + testseam.Swap(t, &skillPathVerify, func(string, string) error { + calls++ + if calls == 2 { + return errors.New("published verify failed") + } + return nil + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "published verify failed") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("verified backup candidate must remain: %v", err) + } + assertNoCrossDeviceStage(t, dst) + }) + + t.Run("source removal failure preserves both", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + if _, err := os.Lstat(dst); err != nil { + t.Fatalf("formal backup must remain: %v", err) + } + }) + + t.Run("read-only source mode is restored after removal failure", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return errors.New("remove failed") + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "均保留") { + t.Fatalf("error = %v", err) + } + if info, err := os.Stat(src); err != nil || info.Mode().Perm() != 0o555 { + t.Fatalf("source directory mode after failed removal = %v, %v", info, err) + } + if err := os.Chmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("source permission preparation failure preserves both", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX directory permission semantics are unavailable") + } + src, dst := makeSkillMoveFixture(t) + if err := os.Chmod(src, 0o555); err != nil { + t.Fatal(err) + } + forceCrossDeviceRename(t, src, dst) + originalChmod := skillPathChmod + testseam.Swap(t, &skillPathChmod, func(path string, mode os.FileMode) error { + if path == src { + return errors.New("chmod failed") + } + return originalChmod(path, mode) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "chmod failed") { + t.Fatalf("error = %v", err) + } + if err := originalChmod(src, 0o755); err != nil { + t.Fatal(err) + } + }) + + t.Run("lying remover is detected", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + forceCrossDeviceRename(t, src, dst) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if path == src { + return nil + } + return originalRemove(path) + }) + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "仍存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) + + t.Run("same-filesystem fast path", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := moveSkillPathRecoverably(src, dst); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(src); !os.IsNotExist(err) { + t.Fatalf("source still exists: %v", err) + } + }) + + t.Run("existing destination is never overwritten", func(t *testing.T) { + src, dst := makeSkillMoveFixture(t) + if err := os.MkdirAll(dst, 0o755); err != nil { + t.Fatal(err) + } + if err := moveSkillPathRecoverably(src, dst); err == nil || !strings.Contains(err.Error(), "目标已存在") { + t.Fatalf("error = %v", err) + } + assertSkillSourcePreserved(t, src) + }) +} + +func TestCrossPlatformCoverageVerifySkillPathCopyMismatches(t *testing.T) { + t.Run("type", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "类型") { + t.Fatalf("error = %v", err) + } + }) + t.Run("directory entries", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Mkdir(src, 0o700); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(dst, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "one"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "数量") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(filepath.Join(dst, "two"), []byte("x"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "目录项") { + t.Fatalf("error = %v", err) + } + }) + t.Run("file size and digest", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.WriteFile(src, []byte("same"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(dst, []byte("longer"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "大小") { + t.Fatalf("error = %v", err) + } + if err := os.WriteFile(dst, []byte("diff"), 0o600); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "摘要") { + t.Fatalf("error = %v", err) + } + }) + t.Run("symlink target", func(t *testing.T) { + root := t.TempDir() + src, dst := filepath.Join(root, "src"), filepath.Join(root, "dst") + if err := os.Symlink("one", src); err != nil { + if runtime.GOOS == "windows" { + t.Skip("symlink creation unavailable") + } + t.Fatal(err) + } + if err := os.Symlink("two", dst); err != nil { + t.Fatal(err) + } + if err := verifySkillPathCopy(src, dst); err == nil || !strings.Contains(err.Error(), "链接目标") { + t.Fatalf("error = %v", err) + } + }) +} + +func forceCrossDeviceRename(t *testing.T, src, dst string) { + t.Helper() + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(from, to string) (string, error) { + if from == src && to == dst { + return "", testCrossDeviceError() + } + return originalRename(from, to) + }) +} + +func makeSkillMoveFixture(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Cleanup(func() { makeSkillPathTreeWritable(root) }) + src := filepath.Join(root, "external", "dingtalk-chat") + dst := filepath.Join(root, "home", ".dws", "skill-backups", "stamp", "dingtalk-chat") + if err := os.MkdirAll(src, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(src, "SKILL.md"), []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + return src, dst +} + +func assertSkillSourcePreserved(t *testing.T, src string) { + t.Helper() + if got, err := os.ReadFile(filepath.Join(src, "SKILL.md")); err != nil || string(got) != "old" { + t.Fatalf("source content = %q, %v", got, err) + } +} + +func assertNoCrossDeviceStage(t *testing.T, dst string) { + t.Helper() + entries, err := os.ReadDir(filepath.Dir(dst)) + if err != nil { + if os.IsNotExist(err) { + return + } + t.Fatal(err) + } + prefix := "." + filepath.Base(dst) + ".cross-device-" + for _, entry := range entries { + if strings.HasPrefix(entry.Name(), prefix) { + t.Fatalf("cross-device staging leaked: %s", entry.Name()) + } + } +} diff --git a/internal/upgrade/skill_path_move_unix.go b/internal/upgrade/skill_path_move_unix.go new file mode 100644 index 00000000..587c8217 --- /dev/null +++ b/internal/upgrade/skill_path_move_unix.go @@ -0,0 +1,12 @@ +//go:build !windows + +package upgrade + +import ( + "errors" + "syscall" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, syscall.EXDEV) +} diff --git a/internal/upgrade/skill_path_move_unix_test.go b/internal/upgrade/skill_path_move_unix_test.go new file mode 100644 index 00000000..288847ae --- /dev/null +++ b/internal/upgrade/skill_path_move_unix_test.go @@ -0,0 +1,16 @@ +//go:build !windows + +package upgrade + +import ( + "os" + "syscall" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: syscall.EXDEV} +} + +func testNoReplaceUnsupportedErrors() []error { + return []error{syscall.EINVAL, syscall.ENOSYS, errNoReplaceRenameUnsupported} +} diff --git a/internal/upgrade/skill_path_move_windows.go b/internal/upgrade/skill_path_move_windows.go new file mode 100644 index 00000000..f43fe688 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows.go @@ -0,0 +1,13 @@ +//go:build windows + +package upgrade + +import ( + "errors" + + "golang.org/x/sys/windows" +) + +func isCrossDeviceError(err error) bool { + return errors.Is(err, windows.ERROR_NOT_SAME_DEVICE) +} diff --git a/internal/upgrade/skill_path_move_windows_test.go b/internal/upgrade/skill_path_move_windows_test.go new file mode 100644 index 00000000..2da189d8 --- /dev/null +++ b/internal/upgrade/skill_path_move_windows_test.go @@ -0,0 +1,24 @@ +//go:build windows + +package upgrade + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func testCrossDeviceError() error { + return &os.LinkError{Op: "rename", Old: "src", New: "dst", Err: windows.ERROR_NOT_SAME_DEVICE} +} + +func testNoReplaceUnsupportedErrors() []error { + return []error{errNoReplaceRenameUnsupported} +} + +func TestCrossPlatformCoverageWindowsCrossDeviceError(t *testing.T) { + if !isCrossDeviceError(testCrossDeviceError()) { + t.Fatal("ERROR_NOT_SAME_DEVICE must enter the cross-device fallback") + } +} diff --git a/internal/upgrade/skill_publication.go b/internal/upgrade/skill_publication.go new file mode 100644 index 00000000..59a3af89 --- /dev/null +++ b/internal/upgrade/skill_publication.go @@ -0,0 +1,241 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "fmt" + "hash" + "os" + "path/filepath" + "sort" +) + +// ErrSkillPathPublicationUncertain marks a publication whose transactional +// outcome cannot be determined safely: this transaction did occupy the +// destination at some point, but a stronger witness — the fresh mkdir-claim +// identity captured by the child-move fallback — now indicates that the +// object currently at the destination may belong to a concurrent writer. +// Callers must not automatically retract, replace, or retry-copy over the +// destination; instead they should surface the state so the user or a +// higher layer can inspect it. Errors wrapping this sentinel are safe to +// test with errors.Is. +var ErrSkillPathPublicationUncertain = errors.New("Skill 发布状态不确定") + +// SkillPathPublication records enough immutable identity to prove that a +// destination still belongs to the transaction that published it. The +// fingerprint is intentionally private so callers cannot forge records. +type SkillPathPublication struct { + Destination string + fingerprint [sha256.Size]byte + identity os.FileInfo + incarnation string + fileID string +} + +// PublishSkillPathNoReplace atomically publishes a staged path without ever +// replacing a destination created after the backup phase. +// +// The rename returns a mkdir-claim identity when (and only when) the +// child-move fallback published dest. That identity is the tamperproof +// witness that dest is still the mkdir claim this transaction created +// (unlike the staged shell, whose presence alone is compatible with a +// wholesale replacement of dest by a concurrent writer). When the claim +// identity no longer matches dest, publication reports uncertain state +// (ErrSkillPathPublicationUncertain) and keeps dest — auto-retracting or +// overwriting would delete the concurrent writer's data. +func PublishSkillPathNoReplace(staged, destination string) (SkillPathPublication, error) { + identity, err := skillPathLstat(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("读取待发布 Skill 身份失败 %s: %w", staged, err) + } + fingerprint, err := fingerprintSkillPath(staged) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("计算待发布 Skill 身份失败 %s: %w", staged, err) + } + stagedFileID := skillPathFileIdentity(staged) + claimIdentity, err := skillPathRenameNoReplace(staged, destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("目标必须不存在的 Skill 发布失败 %s: %w", destination, err) + } + // If the child-move fallback published dest, the claim identity must + // still describe the object at destination. A wholesale replacement of + // dest between the mkdir claim and this check produces a different + // identity — dest is a foreign object and must not be treated as this + // transaction's publication. An unreadable live identity (dest already + // vanished, or the platform cannot report one) defers to the standard + // confirmation checks below instead of guessing. + if claimIdentity != "" { + if liveIdentity := skillPathFileIdentity(destination); liveIdentity != "" && liveIdentity != claimIdentity { + return SkillPathPublication{}, fmt.Errorf("%w:child-move 发布后目标 %s 已被替换;目标保留", ErrSkillPathPublicationUncertain, destination) + } + } + publishedIdentity, err := skillPathLstat(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): %w", destination, err) + } + publishedFingerprint, err := fingerprintSkillPath(destination) + if err != nil { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): %w", destination, err) + } + publishedFileID := skillPathFileIdentity(destination) + // When claim identity is available (child-move path), it is the + // authoritative witness of "dest is our object"; the staged inode + // necessarily differs from the mkdir-claim inode. Only enforce the + // staged-inode identity proof when there is no claim identity — that + // is, when the atomic primitive or the fast-path rename consumed the + // staged inode into dest. + if claimIdentity == "" && !skillPathIdentityProven(identity, publishedIdentity, stagedFileID, publishedFileID) { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 身份失败 %s(对象保留): staging 身份已变化", destination) + } + if publishedFingerprint != fingerprint { + return SkillPathPublication{}, fmt.Errorf("确认已发布 Skill 内容失败 %s(对象保留): staging 内容已变化", destination) + } + return SkillPathPublication{ + Destination: destination, + fingerprint: fingerprint, + identity: publishedIdentity, + incarnation: skillPathFileIncarnation(publishedIdentity), + fileID: publishedFileID, + }, nil +} + +// RollbackSkillPathPublications removes only objects that can still be proven +// to have been published by this transaction. Each live path is first claimed +// into a private sibling quarantine. A concurrent replacement is restored when +// possible, otherwise retained in quarantine and reported explicitly. +func RollbackSkillPathPublications(publications []SkillPathPublication) error { + var rollbackErr error + for i := len(publications) - 1; i >= 0; i-- { + if err := rollbackSkillPathPublication(publications[i]); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + return rollbackErr +} + +func rollbackSkillPathPublication(publication SkillPathPublication) (err error) { + destination := publication.Destination + quarantineRoot, err := skillPathMkdirTemp(filepath.Dir(destination), "."+filepath.Base(destination)+".rollback-") + if err != nil { + return fmt.Errorf("创建 Skill 回滚隔离目录失败 %s: %w", destination, err) + } + quarantine := filepath.Join(quarantineRoot, "payload") + cleanupRoot := func() error { + if cleanupErr := skillPathRemoveAll(quarantineRoot); cleanupErr != nil { + return fmt.Errorf("清理 Skill 回滚隔离目录失败 %s: %w", quarantineRoot, cleanupErr) + } + return nil + } + + liveIdentity, liveIdentityErr := skillPathLstat(destination) + if os.IsNotExist(liveIdentityErr) { + return cleanupRoot() + } + liveFingerprint, liveFingerprintErr := fingerprintSkillPath(destination) + liveVerificationErr := errors.Join(liveIdentityErr, liveFingerprintErr) + if liveVerificationErr != nil { + return errors.Join( + fmt.Errorf("拒绝删除无法验证的 Skill %s: %w", destination, liveVerificationErr), + cleanupRoot(), + ) + } + if publication.identity == nil || + !skillPathIdentityProven(publication.identity, liveIdentity, publication.fileID, skillPathFileIdentity(destination)) || + publication.incarnation != skillPathFileIncarnation(liveIdentity) || + liveFingerprint != publication.fingerprint { + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: 发布对象身份已变化", destination), + cleanupRoot(), + ) + } + + if err := skillPathRename(destination, quarantine); err != nil { + cleanupErr := cleanupRoot() + if os.IsNotExist(err) { + return cleanupErr + } + return errors.Join(fmt.Errorf("隔离待回滚 Skill 失败 %s: %w", destination, err), cleanupErr) + } + + actualIdentity, identityErr := skillPathLstat(quarantine) + actual, fingerprintErr := fingerprintSkillPath(quarantine) + if identityErr == nil && fingerprintErr == nil && + skillPathIdentityProven(publication.identity, actualIdentity, publication.fileID, skillPathFileIdentity(quarantine)) && + actual == publication.fingerprint { + if removeErr := removePublishedSkillSource(quarantine); removeErr != nil { + return fmt.Errorf("移除事务发布的 Skill 失败 %s(隔离于 %s): %w", destination, quarantine, removeErr) + } + return cleanupRoot() + } + + verificationErr := errors.Join(identityErr, fingerprintErr) + if verificationErr == nil { + verificationErr = errors.New("发布对象身份已变化") + } + // Rollback restore is not a publication path; ignore any claim + // identity returned by the rename — no ownership check follows. + if _, restoreErr := skillPathRenameNoReplace(quarantine, destination); restoreErr != nil { + return fmt.Errorf("拒绝删除非本事务 Skill %s: %v;并发对象保留于 %s,恢复原路径失败: %w", destination, verificationErr, quarantine, restoreErr) + } + return errors.Join( + fmt.Errorf("拒绝删除非本事务 Skill %s: %w", destination, verificationErr), + cleanupRoot(), + ) +} + +func fingerprintSkillPath(path string) ([sha256.Size]byte, error) { + h := sha256.New() + if err := fingerprintSkillPathInto(h, path); err != nil { + return [sha256.Size]byte{}, err + } + var result [sha256.Size]byte + copy(result[:], h.Sum(nil)) + return result, nil +} + +func fingerprintSkillPathInto(h hash.Hash, path string) error { + info, err := skillPathLstat(path) + if err != nil { + return err + } + writeFingerprintField(h, info.Mode().Type().String()) + writeFingerprintField(h, info.Mode().Perm().String()) + switch { + case info.IsDir(): + entries, err := skillPathReadDir(path) + if err != nil { + return err + } + sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() }) + writeFingerprintField(h, fmt.Sprintf("%d", len(entries))) + for _, entry := range entries { + writeFingerprintField(h, entry.Name()) + if err := fingerprintSkillPathInto(h, filepath.Join(path, entry.Name())); err != nil { + return err + } + } + return nil + case info.Mode()&os.ModeSymlink != 0: + target, err := skillPathReadlink(path) + if err != nil { + return err + } + writeFingerprintField(h, target) + return nil + case info.Mode().IsRegular(): + digest, err := skillPathFileDigest(path) + if err != nil { + return err + } + writeFingerprintField(h, fmt.Sprintf("%d", info.Size())) + _, err = h.Write(digest[:]) + return err + default: + return fmt.Errorf("不支持识别特殊 Skill 路径 %s(mode=%s)", path, info.Mode()) + } +} + +func writeFingerprintField(h hash.Hash, value string) { + _, _ = fmt.Fprintf(h, "%d:", len(value)) + _, _ = h.Write([]byte(value)) +} diff --git a/internal/upgrade/skill_publication_identity_darwin.go b/internal/upgrade/skill_publication_identity_darwin.go new file mode 100644 index 00000000..0a9989ec --- /dev/null +++ b/internal/upgrade/skill_publication_identity_darwin.go @@ -0,0 +1,50 @@ +//go:build darwin + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Gen, + stat.Ctimespec.Sec, stat.Ctimespec.Nsec, stat.Birthtimespec.Sec, stat.Birthtimespec.Nsec) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems, which the +// publication fingerprint backstop still catches. An unreadable or +// unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := skillPathLstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) +} + +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_linux.go b/internal/upgrade/skill_publication_identity_linux.go new file mode 100644 index 00000000..ff20caf2 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_linux.go @@ -0,0 +1,49 @@ +//go:build linux + +package upgrade + +import ( + "fmt" + "os" + "syscall" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d:%d:%d", stat.Dev, stat.Ino, stat.Ctim.Sec, stat.Ctim.Nsec) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports the device/inode pair from lstat. POSIX +// has no open-by-ID handle, but dev:ino identifies the object for its +// lifetime and is the same witness os.SameFile compares. It anchors the +// mkdir-claim identity of the child-move fallback: a wholesale replacement +// of the claimed destination reports a different identity, except in the +// classic inode-reuse race on recycled-inode filesystems (notably tmpfs), +// which the publication fingerprint backstop still catches. An unreadable +// or unrecognizable path reports "" and callers treat that as "no witness". +func skillPathFileIdentityImpl(path string) string { + info, err := skillPathLstat(path) + if err != nil { + return "" + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return "" + } + return fmt.Sprintf("%d:%d", stat.Dev, stat.Ino) +} + +// skillPathIdentityProven stays SameFile-based on POSIX: the file ID above is +// the same dev:ino witness, so preferring it would add no strength while +// making the proof refuse publishes whose staged lstat (and therefore staged +// identity) could not be read. +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_other.go b/internal/upgrade/skill_publication_identity_other.go new file mode 100644 index 00000000..59bfd256 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_other.go @@ -0,0 +1,27 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +import ( + "fmt" + "os" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + return fmt.Sprintf("%d:%d:%s", info.ModTime().UnixNano(), info.Size(), info.Mode()) +} + +func skillPathSameFileIdentityImpl(left, right os.FileInfo) bool { + return os.SameFile(left, right) +} + +// skillPathFileIdentityImpl reports no identity on platforms whose stat +// layout we do not decode; the child-move claim witness degrades gracefully +// to the publication fingerprint backstop. +func skillPathFileIdentityImpl(_ string) string { + return "" +} + +func skillPathIdentityProven(staged, published os.FileInfo, _, _ string) bool { + return skillPathSameFileIdentity(staged, published) +} diff --git a/internal/upgrade/skill_publication_identity_windows.go b/internal/upgrade/skill_publication_identity_windows.go new file mode 100644 index 00000000..d6740de1 --- /dev/null +++ b/internal/upgrade/skill_publication_identity_windows.go @@ -0,0 +1,64 @@ +//go:build windows + +package upgrade + +import ( + "fmt" + "os" + "syscall" + + "golang.org/x/sys/windows" +) + +func skillPathFileIncarnationImpl(info os.FileInfo) string { + stat, ok := info.Sys().(*syscall.Win32FileAttributeData) + if !ok { + return fmt.Sprintf("unknown:%T", info.Sys()) + } + return fmt.Sprintf("%d:%d", stat.CreationTime.HighDateTime, stat.CreationTime.LowDateTime) +} + +// skillPathSameFileIdentity returns false because os.FileInfo on Windows does +// not expose the stable file ID (VolumeSerialNumber / FileIndex from +// GetFileInformationByHandle), which is the only primitive that distinguishes +// a recreated file from the original after NTFS file tunneling restores the +// creation time. Callers must use skillPathIdentityProven, which compares file +// IDs obtained via GetFileInformationByHandle. +func skillPathSameFileIdentityImpl(_, _ os.FileInfo) bool { + return false +} + +// skillPathFileIdentityImpl queries the stable volume serial number and file +// index via GetFileInformationByHandle. Unlike creation time (which NTFS file +// tunneling can restore for a recreated same-named object), the file index +// uniquely identifies the file on the volume for its lifetime. +// +// FILE_FLAG_OPEN_REPARSE_POINT opens symlinks themselves rather than following +// them to the target. This matters at publish time because a staged symlink +// carries a relative target computed for the final destination, which may not +// resolve from the staging directory. Opening the reparse point yields a file +// ID that is stable across the rename and is the correct identity to compare. +func skillPathFileIdentityImpl(path string) (id string) { + if p, err := windows.UTF16PtrFromString(path); err == nil { + if handle, err := windows.CreateFile( + p, windows.FILE_READ_ATTRIBUTES, + windows.FILE_SHARE_READ|windows.FILE_SHARE_WRITE|windows.FILE_SHARE_DELETE, + nil, windows.OPEN_EXISTING, + windows.FILE_FLAG_BACKUP_SEMANTICS|windows.FILE_FLAG_OPEN_REPARSE_POINT, 0, + ); err == nil { + defer windows.CloseHandle(handle) + var info windows.ByHandleFileInformation + if err := windows.GetFileInformationByHandle(handle, &info); err == nil { + id = fmt.Sprintf("%d:%d:%d", info.VolumeSerialNumber, info.FileIndexHigh, info.FileIndexLow) + } + } + } + return +} + +// skillPathIdentityProven is the real identity proof on Windows. An empty +// expected value means the file ID could not be obtained at publish time, so +// identity cannot be proven and the caller must refuse the auto-delete. +func skillPathIdentityProven(_, _ os.FileInfo, expected, actual string) bool { + return expected != "" && expected == actual +} diff --git a/internal/upgrade/skill_publication_test.go b/internal/upgrade/skill_publication_test.go new file mode 100644 index 00000000..aacf8706 --- /dev/null +++ b/internal/upgrade/skill_publication_test.go @@ -0,0 +1,532 @@ +package upgrade + +import ( + "crypto/sha256" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +func TestCrossPlatformCoverageSkillPublicationNoClobberAndOwnedRollback(t *testing.T) { + t.Run("existing destination is never replaced", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + seedUpgradeSkill(t, destination, "concurrent", false) + + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil { + t.Fatal("expected no-replace publication to fail") + } + assertUpgradeSkillContent(t, destination, "concurrent") + assertUpgradeSkillContent(t, staged, "new") + }) + + t.Run("owned publication is removed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("owned publication remains: %v", err) + } + }) + + t.Run("concurrent replacement is quarantined instead of deleted", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + // Even a byte-for-byte identical replacement is not owned by this + // transaction: inode identity, not content alone, is authoritative. + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) + + t.Run("tunneled replacement with same creation time is still refused", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "new", false) + // NTFS file tunneling can restore the original creation time for a + // same-named recreation, defeating the incarnation check. The file ID + // (or inode on Unix) must still differ and block the rollback. + testseam.Swap(t, &skillPathFileIncarnation, func(os.FileInfo) string { return "tunneled" }) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + // Simulate the identity change that a concurrent replacement causes. + // On Windows, skillPathIdentityProven compares the recorded file ID + // from GetFileInformationByHandle with the live one and ignores the + // SameFile seam; NTFS can reuse the freed MFT record for the + // recreation, so the live probe is perturbed through the file-ID + // seam as well. On Unix, where the file ID is advisory, this swap + // replaces the os.SameFile check (which can return true for a + // recreated file on tmpfs due to inode reuse) so the test is + // deterministic across filesystems. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + return originalFileIdentity(path) + ":replaced" + }) + if err := os.RemoveAll(destination); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, destination, "new", false) + + err = RollbackSkillPathPublications([]SkillPathPublication{publication}) + if err == nil || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("tunneled replacement rollback error = %v", err) + } + assertUpgradeSkillContent(t, destination, "new") + }) +} + +func TestCrossPlatformCoverageSkillPublicationFailureEdges(t *testing.T) { + failure := errors.New("injected publication failure") + + t.Run("platform no-replace path encoding", func(t *testing.T) { + if _, err := renameSkillPathNoReplace("invalid\x00source", filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected invalid source path failure") + } + source := filepath.Join(t.TempDir(), "source") + if err := os.WriteFile(source, []byte("value"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := renameSkillPathNoReplace(source, "invalid\x00destination"); err == nil { + t.Fatal("expected invalid destination path failure") + } + }) + + t.Run("publish identity", func(t *testing.T) { + if _, err := PublishSkillPathNoReplace(filepath.Join(t.TempDir(), "missing"), filepath.Join(t.TempDir(), "dest")); err == nil { + t.Fatal("expected identity failure") + } + }) + + t.Run("publish fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + seedUpgradeSkill(t, staged, "value", false) + originalLstat := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + calls++ + if calls == 2 { + return nil, failure + } + return originalLstat(path) + }) + if _, err := PublishSkillPathNoReplace(staged, filepath.Join(root, "dest")); !errors.Is(err, failure) { + t.Fatal("expected fingerprint failure") + } + }) + + t.Run("publish confirmation missing", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + return "", os.RemoveAll(target) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "对象保留") { + t.Fatalf("publish confirmation error = %v", err) + } + }) + + t.Run("publish confirmation identity changed", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + // A real rename consumed the staged path (fast path), but the object + // at the destination must be proven to be the staged one. Whether a + // same-content swap is observable through inode/file-ID comparison + // depends on the filesystem — CI runners' ext4/overlayfs recycle + // inodes eagerly and NTFS can immediately reuse the freed MFT record, + // so reproducing the swap physically is not portable. Force the + // failure through both underlying primitives the platform-specific + // proof consults: os.SameFile (Unix) and the stable file ID + // (Windows), pinning the confirmation's rejection on every platform. + testseam.Swap(t, &skillPathSameFileIdentity, func(_, _ os.FileInfo) bool { return false }) + originalFileIdentity := skillPathFileIdentity + stagedID := "" + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + id := originalFileIdentity(path) + if stagedID == "" { + stagedID = id + return id + } + // The published probe must report a different object than the + // staged one so the proof fails on both platforms. + return id + ":swapped" + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 身份已变化") { + t.Fatalf("publish identity error = %v", err) + } + assertUpgradeSkillContent(t, destination, "value") + }) + + t.Run("publish confirmation fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + published := false + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + published = true + return "", nil + }) + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if published && path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if _, err := PublishSkillPathNoReplace(staged, destination); !errors.Is(err, failure) || !strings.Contains(err.Error(), "内容失败") { + t.Fatalf("publish fingerprint confirmation error = %v", err) + } + }) + + t.Run("rollback temp", func(t *testing.T) { + testseam.Swap(t, &skillPathMkdirTemp, func(string, string) (string, error) { return "", failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: "dest"}}); !errors.Is(err, failure) { + t.Fatalf("temp error = %v", err) + } + }) + + t.Run("missing destination cleanup", func(t *testing.T) { + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); err != nil { + t.Fatal(err) + } + }) + + t.Run("missing destination cleanup failure", func(t *testing.T) { + testseam.Swap(t, &skillPathRemoveAll, func(string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "missing")}}); !errors.Is(err, failure) { + t.Fatalf("cleanup error = %v", err) + } + }) + + t.Run("quarantine rename", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRename, func(string, string) error { return failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("quarantine error = %v", err) + } + }) + + t.Run("live verification", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalReadDir := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, failure + } + return originalReadDir(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("live verification error = %v", err) + } + }) + + t.Run("destination disappears before quarantine", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if err := os.RemoveAll(source); err != nil { + return err + } + return &os.PathError{Op: "rename", Path: source, Err: os.ErrNotExist} + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); err != nil { + t.Fatal(err) + } + }) + + t.Run("owned remove", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if filepath.Base(path) == "payload" { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("remove error = %v", err) + } + }) + + t.Run("quarantined fingerprint", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "value", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if filepath.Base(path) == "payload" { + return nil, failure + } + return originalLstat(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("fingerprint error = %v", err) + } + }) + + t.Run("mismatch restore and cleanup", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) + originalRemove := skillPathRemoveAll + testseam.Swap(t, &skillPathRemoveAll, func(path string) error { + if strings.Contains(filepath.Base(path), ".destination.rollback-") { + return failure + } + return originalRemove(path) + }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) { + t.Fatalf("mismatch cleanup error = %v", err) + } + assertUpgradeSkillContent(t, destination, "replacement") + }) + + t.Run("mismatch restore failure", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "published", false) + publication, err := PublishSkillPathNoReplace(staged, destination) + if err != nil { + t.Fatal(err) + } + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(source, target string) error { + if source == destination { + if err := os.RemoveAll(source); err != nil { + return err + } + seedUpgradeSkill(t, source, "replacement", false) + } + return originalRename(source, target) + }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", failure }) + if err := RollbackSkillPathPublications([]SkillPathPublication{publication}); !errors.Is(err, failure) || !strings.Contains(err.Error(), "并发对象保留") { + t.Fatalf("mismatch restore error = %v", err) + } + }) + + t.Run("fingerprint read failures and lexical types", func(t *testing.T) { + t.Run("directory ordering", func(t *testing.T) { + dir := t.TempDir() + for _, name := range []string{"b", "a"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte(name), 0o644); err != nil { + t.Fatal(err) + } + } + if _, err := fingerprintSkillPath(dir); err != nil { + t.Fatal(err) + } + }) + t.Run("directory read", func(t *testing.T) { + dir := t.TempDir() + testseam.Swap(t, &skillPathReadDir, func(string) ([]os.DirEntry, error) { return nil, failure }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("read dir error = %v", err) + } + }) + t.Run("recursive child", func(t *testing.T) { + dir := t.TempDir() + child := filepath.Join(dir, "child") + if err := os.WriteFile(child, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == child { + return nil, failure + } + return originalLstat(path) + }) + if _, err := fingerprintSkillPath(dir); !errors.Is(err, failure) { + t.Fatalf("child error = %v", err) + } + }) + t.Run("symlink read", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", failure }) + if _, err := fingerprintSkillPath(link); !errors.Is(err, failure) { + t.Fatalf("readlink error = %v", err) + } + }) + t.Run("symlink success", func(t *testing.T) { + link := filepath.Join(t.TempDir(), "link") + if err := os.Symlink("missing", link); err != nil { + t.Skipf("symlink unavailable: %v", err) + } + if _, err := fingerprintSkillPath(link); err != nil { + t.Fatal(err) + } + }) + t.Run("file digest", func(t *testing.T) { + file := filepath.Join(t.TempDir(), "file") + if err := os.WriteFile(file, []byte("value"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathOpen, func(string) (*os.File, error) { return nil, failure }) + if _, err := fingerprintSkillPath(file); !errors.Is(err, failure) { + t.Fatalf("digest error = %v", err) + } + }) + t.Run("special", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { + return skillPathFakeInfo{mode: os.ModeNamedPipe}, nil + }) + if err := fingerprintSkillPathInto(sha256.New(), "special"); err == nil { + t.Fatal("expected special path error") + } + }) + t.Run("unknown platform identity shape", func(t *testing.T) { + if got := skillPathFileIncarnation(skillPathFakeInfo{}); got == "" { + t.Fatal("empty incarnation") + } + }) + t.Run("synthetic info does not match same-file identity", func(t *testing.T) { + fake := skillPathFakeInfo{} + if skillPathSameFileIdentity(fake, fake) { + t.Fatal("synthetic info should not match") + } + }) + }) +} + +func TestCrossPlatformCoverageUpgradeRollbackRetainsConcurrentReplacement(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + seedUpgradeSkill(t, first, "old first", false) + seedUpgradeSkill(t, second, "old second", false) + stageRoot := filepath.Join(base, ".stage") + stagedFirst := filepath.Join(stageRoot, "dingtalk-first") + stagedSecond := filepath.Join(stageRoot, "dingtalk-second") + seedUpgradeSkill(t, stagedFirst, "new first", false) + seedUpgradeSkill(t, stagedSecond, "new second", false) + + failure := errors.New("injected second publication failure") + originalPublish := upgradePublishSkillPath + calls := 0 + testseam.Swap(t, &upgradePublishSkillPath, func(staged, destination string) (SkillPathPublication, error) { + calls++ + if calls == 2 { + if err := os.RemoveAll(first); err != nil { + t.Fatal(err) + } + seedUpgradeSkill(t, first, "concurrent", false) + return SkillPathPublication{}, failure + } + return originalPublish(staged, destination) + }) + + err := publishStagedSkillSet(home, []stagedSkillDir{ + {staged: stagedFirst, dest: first}, + {staged: stagedSecond, dest: second}, + }, []string{first, second}) + if !errors.Is(err, failure) || !strings.Contains(err.Error(), "拒绝删除非本事务") { + t.Fatalf("transaction error = %v", err) + } + assertUpgradeSkillContent(t, first, "concurrent") + assertUpgradeSkillContent(t, second, "old second") + matches, globErr := filepath.Glob(filepath.Join(home, ".dws", "skill-backups", "*", ".agents-skills-dingtalk-first", "SKILL.md")) + if globErr != nil || len(matches) != 1 { + t.Fatalf("retained old backup = %v, %v", matches, globErr) + } + content, readErr := os.ReadFile(matches[0]) + if readErr != nil || string(content) != "old first" { + t.Fatalf("retained backup content = %q, %v", content, readErr) + } +} diff --git a/internal/upgrade/skill_publish_childmove_edges_test.go b/internal/upgrade/skill_publish_childmove_edges_test.go new file mode 100644 index 00000000..0072228a --- /dev/null +++ b/internal/upgrade/skill_publish_childmove_edges_test.go @@ -0,0 +1,451 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// childMoveDirEntry fakes one directory entry for seam-driven claim scans. +type childMoveDirEntry struct { + name string + isDir bool +} + +func (entry childMoveDirEntry) Name() string { return entry.name } +func (entry childMoveDirEntry) IsDir() bool { return entry.isDir } +func (entry childMoveDirEntry) Type() os.FileMode { return entry.info().Mode().Type() } +func (entry childMoveDirEntry) Info() (os.FileInfo, error) { return entry.info(), nil } +func (entry childMoveDirEntry) info() os.FileInfo { + if entry.isDir { + return skillPathFakeInfo{mode: os.ModeDir | 0o755} + } + return skillPathFakeInfo{mode: 0o644} +} + +// forceChildMove routes every directory publication through the child-move +// slow path: the atomic primitive reports the flag unsupported and the +// fast-path rename fails the way platforms that refuse renaming over any +// existing directory do. +func forceChildMove(t *testing.T) { + t.Helper() + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) +} + +// TestCrossPlatformCoverageChildMoveEdges pins the error and dispatch +// branches of the child-move directory fallback on every platform: the +// platform coverage gates only execute TestCrossPlatformCoverage-named +// tests, so these branches must be driven through seams rather than the +// platform-specific filesystem behaviors that cover them incidentally +// elsewhere. +func TestCrossPlatformCoverageChildMoveEdges(t *testing.T) { + + t.Run("directory source stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathLstat + calls := 0 + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == source { + calls++ + if calls > 1 { + return nil, os.ErrPermission + } + } + return original(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("directory stat failure = %v", err) + } + }) + + t.Run("claim read failure surfaces and reports the cleanup check", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + return nil, os.ErrPermission + } + return os.ReadDir(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || !strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("claim read failure = %v", err) + } + }) + + t.Run("claim vanished during cleanup is tolerated", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 1 { + return nil, os.ErrPermission + } + return nil, os.ErrNotExist + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "降级发布 Skill 目录失败") || strings.Contains(err.Error(), "检查降级发布目标") { + t.Fatalf("vanished claim cleanup = %v", err) + } + }) + + t.Run("file child link failure surfaces and the empty claim cleanup reports", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == destination { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "发布 Skill 文件失败") || !strings.Contains(err.Error(), "清理降级发布目标") { + t.Fatalf("link failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("file child collision = %v, want ErrExist", err) + } + }) + + t.Run("later child failure rolls earlier children back", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.WriteFile(filepath.Join(source, "second"), []byte("second"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The second child's link fails; the forced rename failure then also + // breaks the rollback rename of the already relocated first child. + testseam.Swap(t, &skillPathLink, func(source, _ string) error { + if filepath.Base(source) == "second" { + return os.ErrPermission + } + return os.Link(source, filepath.Join(destination, filepath.Base(source))) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "迁移 Skill 目录项失败") { + t.Fatalf("child failure rollback = %v", err) + } + // The forced rename failure also breaks the rollback restore, so the + // relocated child stays at the destination — retained, never deleted. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("live claim read failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return nil, os.ErrPermission + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "确认降级发布目标") { + t.Fatalf("live claim read failure = %v", err) + } + }) + + t.Run("foreign claim entry aborts the move", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + destinationReads := 0 + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == destination { + destinationReads++ + if destinationReads == 2 { + return []os.DirEntry{ + childMoveDirEntry{name: "SKILL.md"}, + childMoveDirEntry{name: "foreign"}, + }, nil + } + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发目录项") { + t.Fatalf("foreign claim entry = %v", err) + } + }) + + t.Run("claim mode restore failure aborts with rollback", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathChmod, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "恢复已发布 Skill 目录权限") { + t.Fatalf("mode restore failure = %v", err) + } + }) + + t.Run("source shell removal failure surfaces with the destination retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + testseam.Swap(t, &skillPathRemove, func(path string) error { + if path == source { + return os.ErrPermission + } + return os.Remove(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "清理已迁移 Skill 源目录") { + t.Fatalf("shell removal failure = %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("nested directories and mixed children publish", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + if err := os.MkdirAll(filepath.Join(source, "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "nested", "inner.txt"), []byte("inner"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "link"), []byte("stub"), 0o644); err != nil { + t.Fatal(err) + } + forceChildMove(t) + // The "link" child is presented to the dispatcher as a symlink; its + // recreation is simulated through seams so the test needs no + // symlink privileges (Windows runners refuse unprivileged symlinks). + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "link") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return nil }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the claim identity") + } + assertUpgradeSkillContent(t, destination, "payload") + if data, readErr := os.ReadFile(filepath.Join(destination, "nested", "inner.txt")); readErr != nil || string(data) != "inner" { + t.Fatalf("nested child = %q, %v", data, readErr) + } + if _, statErr := os.Lstat(filepath.Join(source, "link")); !os.IsNotExist(statErr) { + t.Fatalf("simulated symlink child must have its source consumed, stat err=%v", statErr) + } + if _, statErr := os.Lstat(source); !os.IsNotExist(statErr) { + t.Fatalf("source must be gone, stat err=%v", statErr) + } + }) + + t.Run("special-type child is refused", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return mockSpecialInfo{}, nil + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("special child = %v, want unsupported", err) + } + }) + + t.Run("symlink child readlink failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "", os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink readlink failure = %v", err) + } + }) + + t.Run("symlink child collision reports ErrExist", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrExist) { + t.Fatalf("symlink collision = %v, want ErrExist", err) + } + }) + + t.Run("symlink child publish failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return skillPathFakeInfo{mode: os.ModeSymlink}, nil + } + return originalLstat(path) + }) + testseam.Swap(t, &skillPathReadlink, func(string) (string, error) { return "target", nil }) + testseam.Swap(t, &skillPathSymlink, func(string, string) error { return os.ErrPermission }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("symlink publish failure = %v", err) + } + }) + + t.Run("child stat failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + originalLstat := skillPathLstat + testseam.Swap(t, &skillPathLstat, func(path string) (os.FileInfo, error) { + if path == filepath.Join(source, "SKILL.md") { + return nil, os.ErrPermission + } + return originalLstat(path) + }) + if _, err := renameSkillPathNoReplace(source, destination); !errors.Is(err, os.ErrPermission) { + t.Fatalf("child stat failure = %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("source read failure aborts with the empty claim cleaned", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + forceChildMove(t) + original := skillPathReadDir + testseam.Swap(t, &skillPathReadDir, func(path string) ([]os.DirEntry, error) { + if path == source { + return nil, os.ErrPermission + } + return original(path) + }) + _, err := renameSkillPathNoReplace(source, destination) + if !strings.Contains(err.Error(), "读取源 Skill 目录失败") { + t.Fatalf("source read failure = %v", err) + } + if _, statErr := os.Lstat(destination); !os.IsNotExist(statErr) { + t.Fatalf("empty claim must be cleaned, stat err=%v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("file identity degrades on unreadable and unrecognizable objects", func(t *testing.T) { + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + if id := skillPathFileIdentityImpl("unreadable"); id != "" { + t.Fatalf("unreadable identity = %q, want empty", id) + } + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return skillPathFakeInfo{mode: 0o644}, nil }) + if id := skillPathFileIdentityImpl("synthetic"); id != "" { + t.Fatalf("unrecognizable identity = %q, want empty", id) + } + }) + + t.Run("published content drift after the fast-path rename surfaces", func(t *testing.T) { + dir := t.TempDir() + staged := filepath.Join(dir, "staged") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, staged, "value", false) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(source, target string) (string, error) { + if _, err := originalRename(source, target); err != nil { + return "", err + } + // The published bytes drift after the rename while the object + // keeps its identity, so only the fingerprint check can refuse. + if err := os.WriteFile(filepath.Join(target, "SKILL.md"), []byte("drifted"), 0o644); err != nil { + return "", err + } + return "", nil + }) + if _, err := PublishSkillPathNoReplace(staged, destination); err == nil || !strings.Contains(err.Error(), "staging 内容已变化") { + t.Fatalf("content drift = %v", err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename.go b/internal/upgrade/skill_publish_rename.go new file mode 100644 index 00000000..ae1742c5 --- /dev/null +++ b/internal/upgrade/skill_publish_rename.go @@ -0,0 +1,290 @@ +package upgrade + +import ( + "errors" + "fmt" + "os" + "path/filepath" +) + +// errNoReplaceRenameUnsupported marks a platform that has no atomic no-replace +// rename primitive at all. +var errNoReplaceRenameUnsupported = errors.New("平台不支持原子 no-replace rename") + +var skillPathRenameNoReplaceAtomic = renameSkillPathNoReplaceAtomic + +// renameSkillPathNoReplace moves source onto destination and never replaces an +// object that already occupies destination. On success it also returns the +// fresh mkdir-claim identity captured by the child-move directory fallback +// (empty when the atomic primitive or the empty-target fast path published +// dest, or when the platform cannot report a stable file identity). The +// publication path uses that identity as a tamperproof witness that dest is +// still the mkdir claim this transaction created; every other caller ignores +// it. +// +// The atomic primitives (Linux RENAME_NOREPLACE, Darwin RENAME_EXCL) require +// support from the underlying filesystem and report EINVAL/ENOTSUP when it is +// missing — rename(2) lists only ext4, btrfs, tmpfs and cifs, so NFS, FUSE and +// overlayfs homes reject the flag. Failing there would abort the whole install +// on those filesystems, so the fallback uses truly atomic no-clobber primitives +// that work on every POSIX filesystem: os.Mkdir for directories (fails with +// EEXIST if the path is occupied), os.Link for regular files (fails with +// EEXIST if the path is occupied), and os.Symlink for symbolic links (same +// EEXIST guarantee). Neither primitive has a TOCTOU window. +func renameSkillPathNoReplace(source, destination string) (string, error) { + err := skillPathRenameNoReplaceAtomic(source, destination) + if err == nil { + return "", nil + } + if !errors.Is(err, errNoReplaceRenameUnsupported) && !isNoReplaceRenameUnsupported(err) { + return "", err + } + return renameSkillPathNoReplaceFallback(source, destination) +} + +// renameSkillPathNoReplaceFallback is used when the kernel no-replace rename +// flag is unavailable. It dispatches on the source type to pick an atomic +// no-clobber primitive that does not require filesystem support. When the +// directory slow path publishes via child-move, the returned claim identity +// is the destination file identity captured immediately after mkdir; every +// other outcome returns an empty claim identity. +func renameSkillPathNoReplaceFallback(source, destination string) (string, error) { + info, err := skillPathLstat(source) + if err != nil { + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + return renameSkillDirNoReplaceFallback(source, destination) + case info.Mode().IsRegular(): + return "", renameSkillFileNoReplaceFallback(source, destination) + default: + return "", &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// renameSkillDirNoReplaceFallback claims the destination once with mkdir and +// never unlinks it for the remainder of the transaction. The fast path +// renames the source over the claim; on platforms that permit replacing an +// empty directory (Linux) that rename only ever touches the claim we own. +// Platforms that refuse to rename over any directory (macOS returns EEXIST +// even for an empty target) take the slow path instead — but only after +// proving the claim is still empty: a rename that failed because a +// concurrent writer landed inside the claim must abort with the destination +// retained, never fall through to child moves whose per-entry primitives +// would collide with (and rollback would delete) the foreign data. The slow +// path moves the source children into the claim one by one, each through an +// atomic no-clobber primitive (mkdir claim for directories, os.Link for +// files, os.Symlink for links), so a same-name concurrent entry aborts the +// move and a foreign entry of any name — detected by re-reading the claim — +// retains the destination instead of letting the rollback delete it. The +// destination claim is removed only while still empty, so a foreign entry +// always survives a failed publication. On success the source is fully +// consumed either way: the fast-path rename moves it onto the claim, and +// the child-move slow path removes the emptied source shell after the last +// child relocates. +// +// The returned claim identity is non-empty only when the child-move slow +// path published dest and the platform can report a stable file identity; +// the fast-path rename consumes the mkdir claim (dest inherits the source +// inode) and returns "", as does an unsuccessful publish. Callers that need +// a tamperproof ownership witness (see PublishSkillPathNoReplace) compare +// the returned identity against skillPathFileIdentity(destination) at the +// moment of the ownership check — a wholesale replacement of dest after +// the claim produces a different identity. +// +// The old fallback (mkdir → rename → remove → rename) had a TOCTOU window +// between the second remove and the second rename during which a concurrent +// writer could create a file or empty directory at dest that the second +// os.Rename would silently overwrite. This implementation removes that +// window: the claim is never unlinked between the fast-path rename attempt +// and publication, and child-move relies exclusively on atomic no-clobber +// primitives that fail with EEXIST rather than replace. +func renameSkillDirNoReplaceFallback(source, destination string) (string, error) { + sourceInfo, err := skillPathLstat(source) + if err != nil { + return "", fmt.Errorf("读取源 Skill 身份失败 %s: %w", source, err) + } + if err := skillPathMkdir(destination, 0o700); err != nil { + if os.IsExist(err) { + return "", &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return "", fmt.Errorf("声明 Skill 发布目标失败 %s: %w", destination, err) + } + // Capture the claim identity immediately after mkdir but before any + // children move in. A concurrent replacement of destination (unlink + + // recreate) after this point produces a different identity. + claimID := skillPathFileIdentity(destination) + if err := skillPathRename(source, destination); err == nil { + // Fast path consumed the source: destination inode is now the + // staged inode, not the mkdir claim. Return no claim identity — + // the caller falls back to the standard staged-identity proof. + return "", nil + } + claimEntries, claimErr := skillPathReadDir(destination) + if claimErr != nil { + return "", errors.Join( + fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, claimErr), + removeClaimIfEmpty(destination), + ) + } + if len(claimEntries) > 0 { + // A concurrent writer populated the empty claim before we could + // take the slow path. Falling through to child-move would let + // its per-entry no-clobber primitives collide with the foreign + // data; abort with the destination retained instead. + return "", fmt.Errorf("降级发布中止,目标在声明后被并发写入 %s: %w", destination, os.ErrExist) + } + if err := moveSkillDirChildrenIntoClaim(source, destination, sourceInfo.Mode().Perm()); err != nil { + return "", fmt.Errorf("降级发布 Skill 目录失败 %s: %w", destination, err) + } + // Child-move consumed every source entry, so the emptied source shell is + // ours by construction. os.Remove refuses a non-empty directory, so a + // concurrent writer inside the staging tree fails the publication loudly + // instead of being silently discarded with the shell. + if err := skillPathRemove(source); err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("清理已迁移 Skill 源目录失败 %s: %w", source, err) + } + // Child-move succeeded: dest is still the mkdir claim we created. + return claimID, nil +} + +// moveSkillDirChildNoReplace moves one child of a staged directory into the +// claimed destination through an atomic no-clobber primitive: directories +// recursively claim with mkdir, regular files publish via os.Link, and +// symlinks are recreated with os.Symlink. Every primitive fails with EEXIST +// when the destination entry was taken concurrently, so no step can replace +// a foreign object. +func moveSkillDirChildNoReplace(source, destination string) error { + info, err := skillPathLstat(source) + if err != nil { + return fmt.Errorf("读取源 Skill 目录项失败 %s: %w", source, err) + } + switch { + case info.IsDir(): + // The nested fallback consumes the nested source on success — the + // fast-path rename moves it away and the child-move slow path + // removes the emptied shell. The nested claim identity is not a + // witness for this transaction (only the top-level destination's + // identity is), so it is discarded here. + _, err := renameSkillDirNoReplaceFallback(source, destination) + return err + case info.Mode().IsRegular(): + return renameSkillFileNoReplaceFallback(source, destination) + case info.Mode()&os.ModeSymlink != 0: + return renameSkillSymlinkNoReplaceFallback(source, destination) + default: + return &os.LinkError{ + Op: "rename", Old: source, New: destination, + Err: fmt.Errorf("%w: 源路径类型不支持降级发布", errNoReplaceRenameUnsupported), + } + } +} + +// removeClaimIfEmpty deletes the claimed destination only while it still +// holds no entries. A foreign entry that appeared after the claim must never +// be removed: the rollback has no ownership proof for it. +func removeClaimIfEmpty(destination string) error { + entries, err := skillPathReadDir(destination) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return fmt.Errorf("检查降级发布目标失败 %s: %w", destination, err) + } + if len(entries) > 0 { + return nil + } + if err := skillPathRemove(destination); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("清理降级发布目标失败 %s: %w", destination, err) + } + return nil +} + +// moveSkillDirChildrenIntoClaim moves every top-level child of source into +// the claimed destination — each through moveSkillDirChildNoReplace, so a +// concurrently created same-name entry fails atomically — and restores the +// source mode on the claim. The claim is re-read after the moves: an entry +// count above the source's means a different-named foreign object landed +// mid-move, and the publication aborts with the destination retained. Any +// failure moves the already relocated children back and removes the claim +// only while it is empty, leaving the source intact and foreign entries in +// place. +func moveSkillDirChildrenIntoClaim(source, destination string, sourceMode os.FileMode) error { + entries, err := skillPathReadDir(source) + if err != nil { + return errors.Join( + fmt.Errorf("读取源 Skill 目录失败 %s: %w", source, err), + removeClaimIfEmpty(destination), + ) + } + moved := make([]string, 0, len(entries)) + rollback := func() error { + var rollbackErr error + for i := len(moved) - 1; i >= 0; i-- { + if err := skillPathRename(filepath.Join(destination, moved[i]), filepath.Join(source, moved[i])); err != nil { + rollbackErr = errors.Join(rollbackErr, err) + } + } + rollbackErr = errors.Join(rollbackErr, removeClaimIfEmpty(destination)) + return rollbackErr + } + for _, entry := range entries { + name := entry.Name() + if err := moveSkillDirChildNoReplace(filepath.Join(source, name), filepath.Join(destination, name)); err != nil { + return errors.Join(fmt.Errorf("迁移 Skill 目录项失败 %s: %w", name, err), rollback()) + } + moved = append(moved, name) + } + live, liveErr := skillPathReadDir(destination) + if liveErr != nil { + return errors.Join( + fmt.Errorf("确认降级发布目标失败 %s: %w", destination, liveErr), + rollback(), + ) + } + if len(live) > len(entries) { + return errors.Join( + fmt.Errorf("降级发布中止,目标出现并发目录项 %s: %w", destination, os.ErrExist), + rollback(), + ) + } + if err := skillPathChmod(destination, sourceMode); err != nil { + return errors.Join( + fmt.Errorf("恢复已发布 Skill 目录权限失败 %s: %w", destination, err), + rollback(), + ) + } + return nil +} + +// renameSkillFileNoReplaceFallback uses os.Link, which atomically fails if the +// destination exists, then removes the source to complete the move. +func renameSkillFileNoReplaceFallback(source, destination string) error { + if err := skillPathLink(source, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 文件失败 %s: %w", destination, err) + } + return skillPathRemove(source) +} + +// renameSkillSymlinkNoReplaceFallback recreates the link at the destination +// with os.Symlink — atomic, EEXIST when taken — and then removes the source. +func renameSkillSymlinkNoReplaceFallback(source, destination string) error { + target, err := skillPathReadlink(source) + if err != nil { + return fmt.Errorf("读取源 Skill 链接失败 %s: %w", source, err) + } + if err := skillPathSymlink(target, destination); err != nil { + if os.IsExist(err) { + return &os.LinkError{Op: "rename", Old: source, New: destination, Err: os.ErrExist} + } + return fmt.Errorf("发布 Skill 链接失败 %s: %w", destination, err) + } + return skillPathRemove(source) +} diff --git a/internal/upgrade/skill_publish_rename_darwin.go b/internal/upgrade/skill_publish_rename_darwin.go new file mode 100644 index 00000000..94df0fb2 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_darwin.go @@ -0,0 +1,22 @@ +//go:build darwin + +package upgrade + +import ( + "errors" + "syscall" + + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { + return unix.RenamexNp(source, destination, unix.RENAME_EXCL) +} + +// Volumes whose filesystem does not implement renamex_np (several network and +// FUSE mounts) reject RENAME_EXCL instead of performing the rename. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.ENOTSUP) || + errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) +} diff --git a/internal/upgrade/skill_publish_rename_linux.go b/internal/upgrade/skill_publish_rename_linux.go new file mode 100644 index 00000000..20d1be77 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_linux.go @@ -0,0 +1,23 @@ +//go:build linux + +package upgrade + +import ( + "errors" + "syscall" + + "golang.org/x/sys/unix" +) + +func renameSkillPathNoReplaceAtomic(source, destination string) error { + return unix.Renameat2(unix.AT_FDCWD, source, unix.AT_FDCWD, destination, unix.RENAME_NOREPLACE) +} + +// rename(2) documents EINVAL as "the filesystem does not support one of the +// flags", which is what NFS, FUSE and overlayfs return; ENOSYS covers kernels +// without renameat2 at all. +func isNoReplaceRenameUnsupported(err error) bool { + return errors.Is(err, syscall.EINVAL) || + errors.Is(err, syscall.ENOSYS) || + errors.Is(err, syscall.EOPNOTSUPP) +} diff --git a/internal/upgrade/skill_publish_rename_other.go b/internal/upgrade/skill_publish_rename_other.go new file mode 100644 index 00000000..c9695771 --- /dev/null +++ b/internal/upgrade/skill_publish_rename_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin && !windows + +package upgrade + +func renameSkillPathNoReplaceAtomic(_, _ string) error { + return errNoReplaceRenameUnsupported +} + +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_rename_test.go b/internal/upgrade/skill_publish_rename_test.go new file mode 100644 index 00000000..6e068eaa --- /dev/null +++ b/internal/upgrade/skill_publish_rename_test.go @@ -0,0 +1,300 @@ +package upgrade + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +type mockSpecialInfo struct{} + +func (mockSpecialInfo) Name() string { return "source" } +func (mockSpecialInfo) Size() int64 { return 0 } +func (mockSpecialInfo) Mode() os.FileMode { return os.ModeNamedPipe } +func (mockSpecialInfo) ModTime() time.Time { return time.Time{} } +func (mockSpecialInfo) IsDir() bool { return false } +func (mockSpecialInfo) Sys() interface{} { return nil } + +// TestCrossPlatformCoverageNoReplaceRenameFallback pins the degradation path for +// filesystems that reject RENAME_NOREPLACE / RENAME_EXCL (NFS, FUSE, overlayfs). +// Those installs must still publish, and must still refuse to clobber an +// occupied destination. The fallback uses atomic no-clobber primitives +// (os.Mkdir for directories, os.Link for files) instead of a TOCTOU-prone +// check-then-rename. +func TestCrossPlatformCoverageNoReplaceRenameFallback(t *testing.T) { + unsupported := testNoReplaceUnsupportedErrors() + + t.Run("publishes a file when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + if data, err := os.ReadFile(destination); err != nil || string(data) != "payload" { + t.Fatalf("destination = %q, %v", data, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("publishes a directory when the filesystem rejects the flag", func(t *testing.T) { + for _, unsupportedErr := range unsupported { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return unsupportedErr }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("fallback must publish for %v, got %v", unsupportedErr, err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + } + }) + + t.Run("still refuses an occupied destination for files", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + for _, path := range []string{source, destination} { + if err := os.WriteFile(path, []byte(filepath.Base(path)), 0o644); err != nil { + t.Fatal(err) + } + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(destination); readErr != nil || string(data) != "destination" { + t.Fatalf("destination must be untouched, got %q, %v", data, readErr) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("file link failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLink, func(string, string) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("link error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("refuses an occupied directory destination via mkdir EEXIST", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + seedUpgradeSkill(t, destination, "existing", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) { + t.Fatalf("occupied destination must report ErrExist, got %v", err) + } + assertUpgradeSkillContent(t, destination, "existing") + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("concurrent entry in the claimed directory aborts with the destination retained", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(_, dst string) error { + // A concurrent writer lands inside the empty claim before the + // slow path can take it; the fast-path rename then fails the + // way it would against a populated target. + if err := os.WriteFile(filepath.Join(dst, "foreign"), []byte("foreign"), 0o644); err != nil { + return err + } + return os.ErrExist + }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrExist) || !strings.Contains(err.Error(), "并发写入") { + t.Fatalf("concurrent claim entry must abort with ErrExist, got %v", err) + } + if data, readErr := os.ReadFile(filepath.Join(destination, "foreign")); readErr != nil || string(data) != "foreign" { + t.Fatalf("foreign entry must be retained, got %q, %v", data, readErr) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("a genuine error is not retried", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + calls := 0 + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { + calls++ + return os.ErrPermission + }) + if _, err := renameSkillPathNoReplace(source, filepath.Join(dir, "destination")); !errors.Is(err, os.ErrPermission) { + t.Fatalf("permission error must surface, got %v", err) + } + if calls != 1 { + t.Fatalf("atomic attempts = %d, want 1", calls) + } + }) + + t.Run("source stat failure surfaces without rename", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "destination") + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return nil, os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("stat error must surface, got %v", err) + } + if _, statErr := os.Lstat(source); statErr != nil { + t.Fatalf("source must be preserved: %v", statErr) + } + }) + + t.Run("cross-device errors still reach the copy fallback", func(t *testing.T) { + crossDevice := testCrossDeviceError() + if isNoReplaceRenameUnsupported(crossDevice) { + t.Fatal("cross-device error must not be treated as an unsupported flag") + } + dir := t.TempDir() + source := filepath.Join(dir, "source") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "SKILL.md"), []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + destination := filepath.Join(dir, "backup", "destination") + original := skillPathRenameNoReplaceAtomic + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(from, to string) error { + if from == source { + return crossDevice + } + return original(from, to) + }) + if err := moveSkillPathRecoverably(source, destination); err != nil { + t.Fatalf("cross-device move = %v", err) + } + if data, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(data) != "payload" { + t.Fatalf("cross-device destination = %q, %v", data, err) + } + }) + + t.Run("mkdir failure with non-EEXIST error surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathMkdir, func(string, os.FileMode) error { return os.ErrPermission }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, os.ErrPermission) { + t.Fatalf("mkdir error must surface, got %v", err) + } + assertUpgradeSkillContent(t, source, "payload") + }) + + t.Run("remove failure after rename failure surfaces", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return errors.New("rename failed") }) + removeErr := errors.New("simulated remove failure") + testseam.Swap(t, &skillPathRemove, func(string) error { return removeErr }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, removeErr) { + t.Fatalf("remove error must surface, got %v", err) + } + if _, statErr := os.Lstat(destination); statErr != nil { + t.Fatalf("destination must still exist after remove failure: %v", statErr) + } + assertUpgradeSkillContent(t, source, "payload") + _ = os.RemoveAll(destination) + }) + + t.Run("fast-path rename failure alone publishes through child-move", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // macOS refuses to rename over any directory, empty or not. The + // claim's verified emptiness — not the rename error — decides + // whether the child-move slow path may proceed. + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("child-move must publish despite rename failure, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be gone, stat err=%v", err) + } + }) + + t.Run("directory publish when first rename replaces empty dir", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + originalRename := skillPathRename + testseam.Swap(t, &skillPathRename, func(src, dst string) error { + _ = os.Remove(dst) + return originalRename(src, dst) + }) + if _, err := renameSkillPathNoReplace(source, destination); err != nil { + t.Fatalf("publish must succeed when rename replaces empty dir, got %v", err) + } + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("non-regular non-directory source fails safely", func(t *testing.T) { + dir := t.TempDir() + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + if err := os.WriteFile(source, []byte("payload"), 0o644); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathLstat, func(string) (os.FileInfo, error) { return mockSpecialInfo{}, nil }) + _, err := renameSkillPathNoReplace(source, destination) + if !errors.Is(err, errNoReplaceRenameUnsupported) { + t.Fatalf("unsupported error must surface for special files, got %v", err) + } + }) +} diff --git a/internal/upgrade/skill_publish_rename_windows.go b/internal/upgrade/skill_publish_rename_windows.go new file mode 100644 index 00000000..18927afe --- /dev/null +++ b/internal/upgrade/skill_publish_rename_windows.go @@ -0,0 +1,22 @@ +//go:build windows + +package upgrade + +import "golang.org/x/sys/windows" + +func renameSkillPathNoReplaceAtomic(source, destination string) error { + sourcePtr, err := windows.UTF16PtrFromString(source) + if err != nil { + return err + } + destinationPtr, err := windows.UTF16PtrFromString(destination) + if err != nil { + return err + } + return windows.MoveFile(sourcePtr, destinationPtr) +} + +// MoveFile without MOVEFILE_REPLACE_EXISTING already refuses an occupied +// destination on every supported Windows version, so there is nothing to +// degrade to. +func isNoReplaceRenameUnsupported(error) bool { return false } diff --git a/internal/upgrade/skill_publish_transaction_test.go b/internal/upgrade/skill_publish_transaction_test.go index 02afa793..dcf1792e 100644 --- a/internal/upgrade/skill_publish_transaction_test.go +++ b/internal/upgrade/skill_publish_transaction_test.go @@ -92,10 +92,10 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("backup failure restores earlier victims", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { if src == filepath.Join(base, "dingtalk-b") { - return failure + return "", failure } return originalRename(src, dest) }) @@ -107,14 +107,14 @@ func TestCrossPlatformCoverageMultiUpgradeTransactionPreservesOldSet(t *testing. t.Run("publish failure rolls back complete set", func(t *testing.T) { home, base, multiRoot, skills, skillSet := seedMultiUpgradeTarget(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-multi-") && filepath.Base(dest) == "dingtalk-b" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMultiUpgradeTarget(home, base, multiRoot, skills, skillSet); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -156,14 +156,14 @@ func TestCrossPlatformCoverageMonoUpgradeTransactionPreservesOldSet(t *testing.T t.Run("publish failure rolls back mono and multi", func(t *testing.T) { home, base, monoRoot := seed(t) - originalRename := upgradeRename + originalPublish := upgradePublishSkillPath failed := false - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { if !failed && strings.Contains(src, ".dws-upgrade-mono-") && filepath.Base(dest) == "dws" { failed = true - return failure + return SkillPathPublication{}, failure } - return originalRename(src, dest) + return originalPublish(src, dest) }) if err := publishMonoUpgradeTarget(home, base, monoRoot); !errors.Is(err, failure) { t.Fatalf("publish failure = %v, want injected failure", err) @@ -211,10 +211,10 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports removal and directory failures", func(t *testing.T) { - testseam.Swap(t, &upgradeRemoveAll, func(string) error { return failure }) + testseam.Swap(t, &upgradeRollbackPublishedSkillPaths, func([]SkillPathPublication) error { return failure }) testseam.Swap(t, &upgradeMkdirAll, func(string, os.FileMode) error { return restoreFailure }) err := restoreSkillSet( - []string{filepath.Join(t.TempDir(), "published")}, + []SkillPathPublication{{Destination: filepath.Join(t.TempDir(), "published")}}, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}, ) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { @@ -223,7 +223,7 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) }) t.Run("restore reports rename failure", func(t *testing.T) { - testseam.Swap(t, &upgradeRename, func(string, string) error { return restoreFailure }) + testseam.Swap(t, &skillPathRenameNoReplace, func(string, string) (string, error) { return "", restoreFailure }) err := restoreSkillSet(nil, []backedUpSkillDir{{original: filepath.Join(t.TempDir(), "old"), backup: filepath.Join(t.TempDir(), "backup")}}) if !errors.Is(err, restoreFailure) { t.Fatalf("restore rename error = %v", err) @@ -236,13 +236,13 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) second := filepath.Join(home, "second") seedUpgradeSkill(t, first, "first", false) seedUpgradeSkill(t, second, "second", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { switch { case src == second: - return failure + return "", failure case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): - return restoreFailure + return "", restoreFailure default: return originalRename(src, dest) } @@ -259,16 +259,15 @@ func TestCrossPlatformCoverageSkillPublishTransactionFailureEdges(t *testing.T) staged := filepath.Join(home, "skills", ".stage", "dws") seedUpgradeSkill(t, old, "old", false) seedUpgradeSkill(t, staged, "new", false) - originalRename := upgradeRename - testseam.Swap(t, &upgradeRename, func(src, dest string) error { - switch { - case src == staged: - return failure - case strings.Contains(filepath.ToSlash(src), skillBackupSubdir): - return restoreFailure - default: - return originalRename(src, dest) + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + return SkillPathPublication{}, failure + }) + originalRename := skillPathRenameNoReplace + testseam.Swap(t, &skillPathRenameNoReplace, func(src, dest string) (string, error) { + if strings.Contains(filepath.ToSlash(src), skillBackupSubdir) { + return "", restoreFailure } + return originalRename(src, dest) }) err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) if !errors.Is(err, failure) || !errors.Is(err, restoreFailure) { diff --git a/internal/upgrade/skill_publish_uncertain_test.go b/internal/upgrade/skill_publish_uncertain_test.go new file mode 100644 index 00000000..f32c2707 --- /dev/null +++ b/internal/upgrade/skill_publish_uncertain_test.go @@ -0,0 +1,176 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 + +package upgrade + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam" +) + +// TestCrossPlatformCoverageUncertainPublicationState pins the contract behind +// ErrSkillPathPublicationUncertain: a child-move publication whose mkdir +// claim no longer describes the destination reports the uncertain sentinel +// instead of guessing, keeps the destination in place, and stops upstream +// retry paths (the linked-copy fallback) from displacing the concurrent +// writer's object. +func TestCrossPlatformCoverageUncertainPublicationState(t *testing.T) { + + t.Run("destination replaced after child-move reports uncertain and is retained", func(t *testing.T) { + root := t.TempDir() + staged := filepath.Join(root, "staged") + destination := filepath.Join(root, "destination") + seedUpgradeSkill(t, staged, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + // Force the child-move slow path on every platform (Linux would + // otherwise publish through the fast-path rename over the claim). + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + // The identity seam simulates the wholesale replacement: the mkdir + // claim captures "claim", every later read of the destination + // reports the replacement's "foreign" identity. This keeps the + // test deterministic on platforms without a stable file identity. + destinationReads := 0 + testseam.Swap(t, &skillPathFileIdentity, func(path string) string { + if path != destination { + return "" + } + destinationReads++ + if destinationReads == 1 { + return "claim" + } + return "foreign" + }) + _, err := PublishSkillPathNoReplace(staged, destination) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("replacement after child-move = %v, want uncertain sentinel", err) + } + if destinationReads != 2 { + t.Fatalf("destination identity reads = %d, want 2 (claim capture + ownership check)", destinationReads) + } + // Nothing was rolled back over the retained destination. + assertUpgradeSkillContent(t, destination, "payload") + }) + + t.Run("child-move reports the real claim identity where the platform supports it", func(t *testing.T) { + dir := t.TempDir() + if id := skillPathFileIdentity(dir); id == "" { + t.Skip("platform reports no stable file identity; the claim witness degrades to the fingerprint backstop") + } + source := filepath.Join(dir, "source") + destination := filepath.Join(dir, "destination") + seedUpgradeSkill(t, source, "payload", false) + testseam.Swap(t, &skillPathRenameNoReplaceAtomic, func(string, string) error { return errNoReplaceRenameUnsupported }) + testseam.Swap(t, &skillPathRename, func(string, string) error { return os.ErrExist }) + claim, err := renameSkillPathNoReplace(source, destination) + if err != nil { + t.Fatal(err) + } + if claim == "" { + t.Fatal("child-move must return the mkdir-claim identity on platforms that report one") + } + if live := skillPathFileIdentity(destination); live == "" || live != claim { + t.Fatalf("destination identity = %q, want the published claim %q", live, claim) + } + }) + + t.Run("uncertain publication passes through the staged-set transaction", func(t *testing.T) { + home := t.TempDir() + old := filepath.Join(home, "skills", "dws") + staged := filepath.Join(home, "skills", ".stage", "dws") + seedUpgradeSkill(t, old, "old", false) + seedUpgradeSkill(t, staged, "new", false) + testseam.Swap(t, &upgradePublishSkillPath, func(string, string) (SkillPathPublication, error) { + return SkillPathPublication{}, fmt.Errorf("并发写入: %w", ErrSkillPathPublicationUncertain) + }) + err := publishStagedSkillSet(home, []stagedSkillDir{{staged: staged, dest: old}}, []string{old}) + if !errors.Is(err, ErrSkillPathPublicationUncertain) { + t.Fatalf("staged-set publish = %v, want uncertain passthrough", err) + } + // The pre-publish victim is restored; the uncertain destination is + // never retried or overwritten. + assertUpgradeSkillContent(t, old, "old") + }) + + t.Run("mono copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dws"), "old mono", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + monoRoot := t.TempDir() + if err := os.WriteFile(filepath.Join(monoRoot, "SKILL.md"), []byte("new mono"), 0o644); err != nil { + t.Fatal(err) + } + linkedDest := filepath.Join(home, ".claude", "skills", "dws") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if dest == linkedDest { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMonoSkillLocations(home, monoRoot) + if err != nil { + t.Fatalf("mono upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedDest && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedDest) + } + }) + + t.Run("multi copy fallback does not retry an uncertain destination", func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".agents", "skills") + seedUpgradeSkill(t, filepath.Join(base, "dingtalk-a"), "old a", false) + if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o755); err != nil { + t.Fatal(err) + } + testseam.Swap(t, &knownSkillDirs, []string{".agents/skills", ".claude/skills"}) + multiRoot := writeMultiBundle(t, t.TempDir(), "dingtalk-a") + linkedBase := filepath.Join(home, ".claude", "skills") + linkedAttempts := 0 + originalPublish := upgradePublishSkillPath + testseam.Swap(t, &upgradePublishSkillPath, func(src, dest string) (SkillPathPublication, error) { + if strings.HasPrefix(dest, linkedBase) { + linkedAttempts++ + return SkillPathPublication{}, fmt.Errorf("%w:链接发布状态不确定", ErrSkillPathPublicationUncertain) + } + return originalPublish(src, dest) + }) + result, err := upgradeMultiSkillLocations(home, multiRoot, []string{"dingtalk-a"}) + if err != nil { + t.Fatalf("multi upgrade = %v", err) + } + if linkedAttempts != 1 { + t.Fatalf("linked publish attempts = %d, want exactly 1 (the copy fallback must not retry an uncertain destination)", linkedAttempts) + } + recorded := false + for _, entry := range result.Results { + if entry.Dir == linkedBase && entry.Status == SkillDirFailed && errors.Is(entry.Err, ErrSkillPathPublicationUncertain) { + recorded = true + } + } + if !recorded { + t.Fatalf("results = %#v, want a recorded uncertain failure for %s", result.Results, linkedBase) + } + }) +} diff --git a/pkg/edition/default.go b/pkg/edition/default.go index 5e0310b6..04e92642 100644 --- a/pkg/edition/default.go +++ b/pkg/edition/default.go @@ -43,11 +43,11 @@ func defaultHooks() *Hooks { } } -// openSupplementServers returns helper-only MCP endpoints owned by the open -// CLI. They are callable by explicit server ID but are deliberately excluded -// from VisibleProducts, so no top-level product command is generated. They -// stay separate from syncdata.StaticServers because only explicitly wired CLI -// helpers may call them; they are not public MCP product surfaces. +// openSupplementServers returns explicitly wired MCP endpoints owned by the +// open CLI. They are callable by explicit server ID but are deliberately +// excluded from VisibleProducts, so this hook never generates a top-level +// product command. A public command using one of these endpoints must register +// its Cobra tree explicitly via helpers.RegisterPublic. func openSupplementServers() []ServerInfo { return []ServerInfo{ { @@ -60,6 +60,12 @@ func openSupplementServers() []ServerInfo { Name: "钉钉白板", Endpoint: "https://mcp-gw.dingtalk.com/server/whiteboard", }, + { + ID: "recruit", + Name: "钉钉招聘", + Endpoint: "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff", + Prefixes: []string{"recruit", "job"}, + }, } } diff --git a/pkg/edition/edition_test.go b/pkg/edition/edition_test.go index c581c714..7418836e 100644 --- a/pkg/edition/edition_test.go +++ b/pkg/edition/edition_test.go @@ -107,7 +107,12 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { servers := openSupplementServers() foundMCPMeta := false foundWhiteboard := false + foundRecruit := false for _, server := range servers { + if server.ID == "recruit" { + foundRecruit = server.Endpoint == "https://mcp-gw.dingtalk.com/server/f69b54ada16c57b603c0e5e1c36f464ba73dcee28d64bb701ff2682c259c0cff" && + len(server.Prefixes) == 2 && server.Prefixes[0] == "recruit" && server.Prefixes[1] == "job" + } if server.ID == "whiteboard" { foundWhiteboard = server.Endpoint == "https://mcp-gw.dingtalk.com/server/whiteboard" } @@ -128,4 +133,7 @@ func TestOpenSupplementServersIncludesMCPMeta(t *testing.T) { if !foundWhiteboard { t.Fatal("openSupplementServers() missing helper-only whiteboard endpoint") } + if !foundRecruit { + t.Fatal("openSupplementServers() missing explicitly wired recruit endpoint") + } } diff --git a/scripts/ci/run-app-race-tests.sh b/scripts/ci/run-app-race-tests.sh index 3f586dd0..0183762c 100755 --- a/scripts/ci/run-app-race-tests.sh +++ b/scripts/ci/run-app-race-tests.sh @@ -14,7 +14,7 @@ usage() { # Single source of truth for the partition set. CI runs one job per partition and # pins its shard names to this list, so a name that appears here without a # dispatch entry below fails closed rather than silently skipping tests. -APP_PARTITIONS='schema a-b c d-r s-z-example-fuzz' +APP_PARTITIONS='schema a-b c-a-l c-m-r c-s-z c-other d-r s-z-example-fuzz' mode="${1:-}" partition="" @@ -66,7 +66,10 @@ fi schema_count=0 ab_count=0 -c_count=0 +cal_count=0 +cmr_count=0 +csz_count=0 +cother_count=0 dr_count=0 sz_count=0 unmatched_count=0 @@ -75,7 +78,10 @@ while IFS= read -r test_name; do case "$test_name" in Test*Schema*) schema_count=$((schema_count + 1)) ;; Test[A-B]*) ab_count=$((ab_count + 1)) ;; - TestC*) c_count=$((c_count + 1)) ;; + TestCrossPlatformCoverage[A-L]*) cal_count=$((cal_count + 1)) ;; + TestCrossPlatformCoverage[M-R]*) cmr_count=$((cmr_count + 1)) ;; + TestCrossPlatformCoverage[S-Z]*) csz_count=$((csz_count + 1)) ;; + TestC*) cother_count=$((cother_count + 1)) ;; Test[D-R]*) dr_count=$((dr_count + 1)) ;; Test[S-Z]*|Example*|Fuzz*) sz_count=$((sz_count + 1)) ;; *) @@ -96,7 +102,10 @@ classified='' for spec in \ "schema:$schema_count" \ "a-b:$ab_count" \ - "c:$c_count" \ + "c-a-l:$cal_count" \ + "c-m-r:$cmr_count" \ + "c-s-z:$csz_count" \ + "c-other:$cother_count" \ "d-r:$dr_count" \ "s-z-example-fuzz:$sz_count" do @@ -133,14 +142,14 @@ for name in $classified; do done total_count="$(wc -l < "$tests" | tr -d ' ')" -assigned_count=$((schema_count + ab_count + c_count + dr_count + sz_count)) +assigned_count=$((schema_count + ab_count + cal_count + cmr_count + csz_count + cother_count + dr_count + sz_count)) if [ "$assigned_count" -ne "$total_count" ]; then printf 'app race partitions assigned %s tests, want %s\n' "$assigned_count" "$total_count" >&2 exit 1 fi -printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c=%s d-r=%s s-z-example-fuzz=%s\n' \ - "$total_count" "$schema_count" "$ab_count" "$c_count" "$dr_count" "$sz_count" +printf 'app race partitions cover %s top-level tests exactly once: schema=%s a-b=%s c-a-l=%s c-m-r=%s c-s-z=%s c-other=%s d-r=%s s-z-example-fuzz=%s\n' \ + "$total_count" "$schema_count" "$ab_count" "$cal_count" "$cmr_count" "$csz_count" "$cother_count" "$dr_count" "$sz_count" if [ "$mode" = "verify" ]; then exit 0 @@ -198,7 +207,10 @@ run_named_partition() { case "$1" in schema) run_partition schema no-race "$schema_pattern" ;; a-b) run_partition a-b race '^Test[A-B]' "$schema_pattern" ;; - c) run_partition c race '^TestC' "$schema_pattern" ;; + c-a-l) run_partition c-a-l race '^TestCrossPlatformCoverage[A-L]' "$schema_pattern" ;; + c-m-r) run_partition c-m-r race '^TestCrossPlatformCoverage[M-R]' "$schema_pattern" ;; + c-s-z) run_partition c-s-z race '^TestCrossPlatformCoverage[S-Z]' "$schema_pattern" ;; + c-other) run_partition c-other race '^TestC' '^Test.*Schema|^TestCrossPlatformCoverage' ;; d-r) run_partition d-r race '^Test[D-R]' "$schema_pattern" ;; s-z-example-fuzz) run_partition s-z-example-fuzz race '^(Test[S-Z]|Example|Fuzz)' "$schema_pattern" diff --git a/scripts/dev/test-multi-profile-e2e.sh b/scripts/dev/test-multi-profile-e2e.sh index 50cba792..d24371b7 100755 --- a/scripts/dev/test-multi-profile-e2e.sh +++ b/scripts/dev/test-multi-profile-e2e.sh @@ -638,13 +638,13 @@ MONO_HOME="$WORKDIR/home-mono" MULTI_HOME="$WORKDIR/home-multi" mkdir -p "$MONO_HOME" "$MULTI_HOME" capture "$OUT_DIR/skill-mono-setup.txt" env HOME="$MONO_HOME" "$BIN" skill setup --mode mono --target codex --source "$ROOT" --yes -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "corpId:userId" -expect_contains "$MONO_HOME/.codex/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MONO_HOME/.codex/skills/dws/references/global-reference.md" "userId/userName" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "corpId:userId" +expect_contains "$MONO_HOME/.agents/skills/dws/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MONO_HOME/.agents/skills/dws/references/global-reference.md" "userId/userName" capture "$OUT_DIR/skill-multi-setup.txt" env HOME="$MULTI_HOME" "$BIN" skill setup --mode multi --target codex --source "$ROOT" --yes -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "corpId:userId" -expect_contains "$MULTI_HOME/.codex/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-shared/SKILL.md" "禁止选择第一项、最近登录或最近使用账号" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "corpId:userId" +expect_contains "$MULTI_HOME/.agents/skills/dingtalk-misc/references/profile.md" "isOrgCurrent" log "verifying empty profile list" capture "$OUT_DIR/list-empty.json" "$BIN" profile list --format json diff --git a/scripts/gen_shortcut_public_catalog.py b/scripts/gen_shortcut_public_catalog.py index 0b37827d..de3a1f2d 100644 --- a/scripts/gen_shortcut_public_catalog.py +++ b/scripts/gen_shortcut_public_catalog.py @@ -27,6 +27,11 @@ SEMANTIC_PATHS = [ ROOT / "internal" / "shortcut" / "semantic_catalog_wiki.json", ROOT / "internal" / "shortcut" / "semantic_catalog_calendar.json", ROOT / "internal" / "shortcut" / "semantic_catalog_todo.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_attendance.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_mail.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_oa.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_ding.json", + ROOT / "internal" / "shortcut" / "semantic_catalog_report.json", ] diff --git a/scripts/install-devapp.ps1 b/scripts/install-devapp.ps1 index 894e4f5e..686db8e1 100644 --- a/scripts/install-devapp.ps1 +++ b/scripts/install-devapp.ps1 @@ -22,9 +22,529 @@ $InstallDir = if ($env:DWS_INSTALL_DIR) { $env:DWS_INSTALL_DIR } else { Join-Pat $NoSkills = $env:DWS_NO_SKILLS -eq "1" $SkillName = "dingtalk-misc" +# vercel-labs/skills agents.ts (c6f69c6). Each row is +# id|universal|effective global directory; '-' means no global directory. +$AgentRegistryRows = @( + "aider-desk|0|.aider-desk\skills", "amp|1|.config\agents\skills", + "antigravity|1|.gemini\antigravity\skills", "antigravity-cli|1|.gemini\antigravity-cli\skills", + "astrbot|0|.astrbot\data\skills", "autohand-code|0|.autohand\skills", "augment|0|.augment\skills", + "bob|0|.bob\skills", "claude-code|0|.claude\skills", "openclaw|0|.openclaw\skills", + "cline|1|.agents\skills", "codearts-agent|0|.codeartsdoer\skills", "codebuddy|0|.codebuddy\skills", + "codemaker|0|.codemaker\skills", "codestudio|0|.codestudio\skills", "codex|1|.codex\skills", + "command-code|0|.commandcode\skills", "continue|0|.continue\skills", "cortex|0|.snowflake\cortex\skills", + "crush|0|.config\crush\skills", "cursor|1|.cursor\skills", "deepagents|1|.deepagents\agent\skills", + "devin|0|.config\devin\skills", "dexto|1|.agents\skills", "droid|0|.factory\skills", "eve|0|-", + "firebender|1|.firebender\skills", "forgecode|0|.forge\skills", "gemini-cli|1|.gemini\skills", + "github-copilot|1|.copilot\skills", "goose|0|.config\goose\skills", "grok|0|.grok\skills", + "hermes-agent|0|.hermes\skills", "inference-sh|0|.inferencesh\skills", "jazz|0|.jazz\skills", + "junie|0|.junie\skills", "iflow-cli|0|.iflow\skills", "kilo|0|.kilocode\skills", + "kimchi|0|.config\kimchi\harness\skills", "kimi-code-cli|1|.agents\skills", "kiro-cli|0|.kiro\skills", + "kode|0|.kode\skills", "lingma|0|.lingma\skills", "loaf|1|.agents\skills", "mcpjam|0|.mcpjam\skills", + "minimax-code|0|.minimax\skills", "mistral-vibe|0|.vibe\skills", "moxby|0|.moxby\skills", + "mux|0|.mux\skills", "opencode|1|.config\opencode\skills", "openhands|0|.openhands\skills", + "ona|0|.ona\skills", "pi|0|.pi\agent\skills", "qoder|0|.qoder\skills", "qoder-cn|0|.qoder-cn\skills", + "qwen-code|0|.qwen\skills", "replit|1|.config\agents\skills", "reasonix|0|.reasonix\skills", + "rovodev|0|.rovodev\skills", "roo|0|.roo\skills", "tabnine-cli|0|.tabnine\agent\skills", + "terramind|0|.terramind\skills", "tinycloud|0|.tinycloud\skills", "trae|0|.trae\skills", + "trae-cn|0|.trae-cn\skills", "warp|1|.agents\skills", "windsurf|0|.codeium\windsurf\skills", + "zed|1|.agents\skills", "zcode|0|.zcode\skills", "zencoder|0|.zencoder\skills", + "zenflow|0|.zencoder\skills", "neovate|0|.neovate\skills", "pochi|0|.pochi\skills", + "promptscript|1|-", "adal|0|.adal\skills", "universal|1|.config\agents\skills" +) +$LegacyAgentCleanupRows = @( + "dws-qoderwork|0|.qoderwork\skills", "dws-legacy-github|1|.github\skills", + "dws-legacy-amp|1|.amp\skills", "dws-legacy-cline|1|.cline\skills", + "dws-legacy-windsurf|1|.windsurf\skills" +) + function Say($m) { Write-Host " $m" } function Die($m) { Write-Host " X $m" -ForegroundColor Red; exit 1 } +function Resolve-AgentBase([string]$row) { + $parts = $row.Split('|') + $id = $parts[0] + if ($parts[2] -eq "-") { return $null } + switch ($id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return Join-Path $env:AUTOHAND_HOME "skills" } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return Join-Path $env:CLAUDE_CONFIG_DIR "skills" } } + "codex" { if ($env:CODEX_HOME) { return Join-Path $env:CODEX_HOME "skills" } } + "grok" { if ($env:GROK_HOME) { return Join-Path $env:GROK_HOME "skills" } } + "hermes-agent" { if ($env:HERMES_HOME) { return Join-Path $env:HERMES_HOME "skills" } } + "mistral-vibe" { if ($env:VIBE_HOME) { return Join-Path $env:VIBE_HOME "skills" } } + "openclaw" { + foreach ($legacy in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $HOME $legacy + if (Test-Path $candidate -PathType Container) { return Join-Path $candidate "skills" } + } + } + { $_ -in @("amp", "replit", "universal") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + return Join-Path $xdg "agents\skills" + } + { $_ -in @("crush", "devin", "goose", "kimchi", "opencode") } { + $xdg = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $HOME ".config" } + $child = switch ($id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; "kimchi" { "kimchi\harness\skills" }; default { "opencode\skills" } } + return Join-Path $xdg $child + } + } + return Join-Path $HOME $parts[2] +} + +function Test-AgentBaseDetected([string]$id, [string]$base) { + $parent = Split-Path $base -Parent + switch ($id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + +function Test-PathLexically([string]$path) { + try { Get-Item -LiteralPath $path -Force -ErrorAction Stop | Out-Null; return $true } catch {} + $parent = Split-Path $path -Parent; $leaf = Split-Path $path -Leaf + if (!(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) +} + +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "Skill move destination already exists: $Destination" } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + [System.IO.Directory]::Move($Source, $Destination) + } else { + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-DevSkillPathLexically $Source + } catch { + $removeErr = $_ + try { + Remove-DevSkillPathLexically $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } + } +} + +function Test-DevCrossDeviceMoveError([System.Management.Automation.ErrorRecord]$Record) { + $exception = $Record.Exception + while ($null -ne $exception) { + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-DevSkillMetadata($SourceItem, [string]$Destination) { + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-DevSkillPermissionFingerprint([string]$Path) { + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-DevSkillPathLexically([string]$Source, [string]$Destination) { + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-DevSkillPathLexically $child.FullName (Join-Path $Destination $child.Name) + } + Copy-DevSkillMetadata $item $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { throw "unsupported special Skill path $Source" } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-DevSkillMetadata $item $Destination +} + +function Assert-DevSkillPathCopy([string]$Source, [string]$Destination) { + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill path type mismatch: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill link target mismatch: $Source != $Destination" + } + return + } + if ((Get-DevSkillPermissionFingerprint $Source) -ne (Get-DevSkillPermissionFingerprint $Destination)) { + throw "Skill path permissions mismatch: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { throw "Skill directory entries mismatch: $Source != $Destination" } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { throw "Skill directory entries mismatch: $Source != $Destination" } + Assert-DevSkillPathCopy $sourceChildren[$i].FullName $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { throw "Skill file size mismatch: $Source != $Destination" } + if ((Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash -ne + (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash) { + throw "Skill file digest mismatch: $Source != $Destination" + } +} + +function Remove-DevSkillPathLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Removes a staging directory that may hold junction/symlink children. Link +# children must be removed non-recursively first: Windows PowerShell 5.1 can +# follow reparse points during Remove-Item -Recurse and would delete the +# canonical store's contents through the staged link. +function Remove-DevLinkStageRoot([string]$StageRoot) { + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-DevSkillPathLexically $child.FullName } catch { $ok = $false } + } + try { Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop } catch { $ok = $false } + return $ok +} + +function Move-DevSkillPathRecoverably([string]$Source, [string]$Destination) { + if (Test-PathLexically $Destination) { throw "move destination already exists: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-DevSkillPath $Source $Destination + return + } catch { + if (!(Test-DevCrossDeviceMoveError $_)) { throw } + } + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Move-DevSkillPath $stage $Destination + $published = $true + try { + Assert-DevSkillPathCopy $Source $Destination + if (!(Remove-DevLinkStageRoot $stageRoot)) { + throw "Skill staging cleanup failed: $stageRoot" + } + } catch { + $postErr = $_ + try { + # Retract only after proving the destination is still this + # transaction's copy (mirrors install.ps1's + # Remove-PublishedSkillPathSafely retract): a path-blind + # lexical removal would delete a concurrent replacement. + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $Source "" + } + } catch { + throw "Skill move state uncertain: $postErr; failed to retract $Destination`: $_; source $Source and dest $Destination retained" + } + throw "Skill move failed, dest retracted, source retained ${Source}: $postErr" + } + try { Remove-DevSkillPathLexically $Source } catch { + throw "Skill target published but source removal failed; both retained ($Source, $Destination): $_" + } + if (Test-PathLexically $Source) { throw "Skill source still exists; both retained ($Source, $Destination)" } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + if (!(Remove-DevLinkStageRoot $stageRoot)) { + if (-not $published) { + throw "$failure; cross-device Skill staging cleanup failed $stageRoot (backup and original retained)" + } + } + } + throw $failure + } +} + +# Get-DevSkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-misc' → '.codex-skills-dingtalk-misc') so +# copies retired from different Agent roots stay distinguishable. Mirrors +# build/npm/install.js and internal/upgrade/paths.go; paths outside HOME fall +# back to the bare leaf. +function Get-DevSkillBackupName([string]$Dir) { + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { return $leaf } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# Keep $HOME\.dws\skill-backups bounded to the newest stamped roots, matching +# skillBackupKeep / pruneSkillBackups in internal/upgrade/paths.go. Roots this +# run created are never pruned: a rollback still needs them. +$DevSkillBackupKeep = 5 +$script:DevSkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$DevSkillBackupMarkerFile = ".dws-skill-backup" +$DevSkillBackupMarkerBody = "dws skill backup v1" + +# Write-DevSkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-DevSkillBackupMarker([string]$Root) { + [System.IO.File]::WriteAllText( + (Join-Path $Root $DevSkillBackupMarkerFile), + "$DevSkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-DevSkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-DevSkillBackupMarker([string]$Dir) { + try { + $marker = Join-Path $Dir $DevSkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $DevSkillBackupMarkerBody) + } catch { + return $false + } +} + +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-DevLinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-DevSkillBackupTreeLexically([string]$Path) { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-DevSkillBackupTreeLexically $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + +function Remove-OldDevSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $DevSkillBackupKeep. + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-DevSkillBackupMarker $_.FullName) } | + Sort-Object -Property Name) + $excess = $dirs.Count - $DevSkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:DevSkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + try { Remove-DevSkillBackupTreeLexically $dir.FullName } catch { } + $excess-- + } +} + +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + if ($null -ne $BackupPath) { $BackupPath.Value = "" } + if (!(Test-PathLexically $path)) { return } + $backupBase = Join-Path $HOME ".dws\skill-backups" + $stamp = [DateTime]::UtcNow.ToString('yyyyMMdd-HHmmss') + $name = Get-DevSkillBackupName $path + $backupRoot = Join-Path $backupBase $stamp + $target = Join-Path $backupRoot $name + $i = 1 + while (Test-PathLexically $target) { + $backupRoot = Join-Path $backupBase "$stamp-$i" + $target = Join-Path $backupRoot $name + $i++ + if ($i -gt 1000) { throw "备份目录冲突无法解决,保留原目录: $path" } + } + New-Item -ItemType Directory -Path $backupRoot -Force -ErrorAction Stop | Out-Null + # Stamp ownership immediately after creating the stamp root and before + # any skill directory moves into it, so an interrupted backup can never + # leave an unmarked (never-prunable) stamp behind. + try { + Write-DevSkillBackupMarker $backupRoot + } catch { + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $backupRoot -Force -ErrorAction SilentlyContinue + throw + } + Move-DevSkillPathRecoverably $path $target + if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:DevSkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($backupRoot)) | Out-Null + Remove-OldDevSkillBackups + } catch { + Say "⚠️ 旧备份清理失败(备份本身已成功): $($_.Exception.Message)" + } +} + +function Get-DevSkillLinkSignature([string]$Path) { + try { $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop } catch { return $null } + if (!$item.LinkType) { return $null } + return ([string]$item.LinkType + "`0" + ((@($item.Target) | ForEach-Object { [string]$_ }) -join "`0")) +} + +function New-DevRollbackFailure([string]$Message) { + $failure = [System.InvalidOperationException]::new($Message) + $failure.Data["DWSRollbackFailed"] = $true + return $failure +} + +function Remove-VerifiedDevSkillPublication( + [string]$Destination, + [string]$PublishedSource, + [string]$PublishedLinkSignature +) { + $parent = Split-Path $Destination -Parent + $quarantine = Join-Path $parent (".dws-dev-rollback-" + [guid]::NewGuid().ToString("N")) + Move-DevSkillPath $Destination $quarantine + try { + if (![string]::IsNullOrWhiteSpace($PublishedLinkSignature)) { + if ((Get-DevSkillLinkSignature $quarantine) -ne $PublishedLinkSignature) { + throw "published Skill link was replaced by another process: $Destination" + } + } elseif (![string]::IsNullOrWhiteSpace($PublishedSource)) { + Assert-DevSkillPathCopy $PublishedSource $quarantine + } else { + throw "Skill restore destination is occupied by another process: $Destination" + } + Remove-DevSkillPathLexically $quarantine + } catch { + $failure = $_ + if (Test-PathLexically $quarantine) { + try { + if (Test-PathLexically $Destination) { throw "original path is occupied" } + Move-DevSkillPath $quarantine $Destination + } catch { + throw "$failure; concurrent object retained at $quarantine`: $_" + } + } + throw $failure + } +} + +function Restore-DevSkillBackup( + [string]$Destination, + [string]$Backup, + [string]$PublishedSource = "", + [string]$PublishedLinkSignature = "" +) { + if (Test-PathLexically $Destination) { + Remove-VerifiedDevSkillPublication $Destination $PublishedSource $PublishedLinkSignature + } + if (![string]::IsNullOrWhiteSpace($Backup)) { + Move-DevSkillPathRecoverably $Backup $Destination + } +} + +function Publish-DevSkillCopy([string]$Source, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-copy-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + $publishedSource = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + Copy-DevSkillPathLexically $Source $stage + Assert-DevSkillPathCopy $Source $stage + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $publishedSource = $Source + Assert-DevSkillPathCopy $Source $Destination + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup $publishedSource "" } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") + } + throw $publishFailure + } + } finally { + Remove-DevLinkStageRoot $stageRoot | Out-Null + } +} + +function Publish-DevSkillJunction([string]$Target, [string]$Destination) { + $parent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $parent -Force -ErrorAction Stop | Out-Null + $stageRoot = Join-Path $parent (".dws-dev-link-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + $backup = "" + $publishedSignature = "" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + try { + New-Item -ItemType Junction -Path $stage -Target ([System.IO.Path]::GetFullPath($Target)) -ErrorAction Stop | Out-Null + Backup-DevSkill $Destination ([ref]$backup) + try { + Move-DevSkillPath $stage $Destination + $published = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if (!$published.LinkType) { throw "published Skill path is not a junction: $Destination" } + $publishedSignature = Get-DevSkillLinkSignature $Destination + if ([string]::IsNullOrEmpty($publishedSignature)) { throw "published Skill link identity is unavailable: $Destination" } + } catch { + $publishFailure = $_ + try { Restore-DevSkillBackup $Destination $backup "" $publishedSignature } catch { + throw (New-DevRollbackFailure "$publishFailure; Skill rollback failed; backup retained at $backup`: $_") + } + throw $publishFailure + } + } finally { + # The staged payload is a junction to the canonical store; it must be + # removed lexically or Windows PowerShell 5.1 recursion could delete + # canonical content through the reparse point. + Remove-DevLinkStageRoot $stageRoot | Out-Null + } +} + function Get-Arch { if ($env:DWS_ARCH -eq "amd64" -or $env:DWS_ARCH -eq "arm64") { return $env:DWS_ARCH } try { @@ -40,6 +560,26 @@ function Get-Arch { } } +function Assert-DevReleaseAssetChecksum([string]$AssetPath, [string]$AssetName, [string]$TempDir) { + $checksums = Join-Path $TempDir "checksums.txt" + if (!(Test-Path -LiteralPath $checksums -PathType Leaf)) { + try { + Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/checksums.txt" ` + -OutFile $checksums -UseBasicParsing -ErrorAction Stop + } catch { + Die "Could not download checksums.txt; refusing unverified release assets." + } + } + $line = Get-Content -LiteralPath $checksums | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $line) { Die "$AssetName is missing from checksums.txt." } + $expected = ($line -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Die "SHA256 checksum mismatch for $AssetName." } + Say "SHA256 checksum verified: $AssetName" +} + # Read the releases list (newest first) and take the top tag, so this also works # if a release is ever published as a prerelease (which /releases/latest skips). if (-not $Version) { @@ -70,6 +610,7 @@ try { Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/$asset" ` -OutFile $zip -UseBasicParsing } catch { Die "Binary download failed - does release $Version have $asset?" } +Assert-DevReleaseAssetChecksum $zip $asset $tmp Expand-Archive -Path $zip -DestinationPath $tmp -Force $exe = Get-ChildItem -Path $tmp -Recurse -Filter "dws.exe" | Select-Object -First 1 @@ -84,6 +625,7 @@ if (-not $NoSkills) { $skzip = Join-Path $tmp "dws-skills.zip" Invoke-WebRequest -Uri "https://github.com/$Repo/releases/download/$Version/dws-skills.zip" ` -OutFile $skzip -UseBasicParsing + Assert-DevReleaseAssetChecksum $skzip "dws-skills.zip" $tmp $skdir = Join-Path $tmp "sk" Expand-Archive -Path $skzip -DestinationPath $skdir -Force @@ -95,33 +637,63 @@ if (-not $NoSkills) { if ($src) { # cache so `dws skill setup --mode multi` can find a source later $cache = Join-Path $HOME ".dws\skills\multi\$SkillName" - if (Test-Path $cache) { Remove-Item -Recurse -Force $cache } - New-Item -ItemType Directory -Path $cache -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $cache -Recurse -Force + Publish-DevSkillCopy $src $cache - $agentDirs = @( - ".agents\skills", ".claude\skills", ".cursor\skills", ".qoder\skills", ".qoderwork\skills", - ".gemini\skills", ".codex\skills", ".github\skills", ".windsurf\skills", ".augment\skills", - ".cline\skills", ".amp\skills", ".kiro\skills", ".trae\skills", ".openclaw\skills", - ".hermes\skills", ".config\opencode\skills" - ) - $installed = 0; $idx = 0 - foreach ($d in $agentDirs) { - $base = Join-Path $HOME $d - $parent = Split-Path $base -Parent - if ($idx -gt 0 -and -not (Test-Path $parent)) { $idx++; continue } + $canonicalBase = Join-Path $HOME ".agents\skills" + $canonical = Join-Path $canonicalBase $SkillName + Publish-DevSkillCopy $src $canonical + $installed = 1 + $failed = 0 + $retireFailed = 0 + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($row in @($AgentRegistryRows) + @($LegacyAgentCleanupRows)) { + $parts = $row.Split('|'); $universal = $parts[1] -eq "1" + $base = Resolve-AgentBase $row + if ([string]::IsNullOrWhiteSpace($base)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($base).TrimEnd([char[]]@('\', '/')) + if (!$seen.Add($baseKey) -or $baseKey -eq [System.IO.Path]::GetFullPath($canonicalBase).TrimEnd([char[]]@('\', '/'))) { continue } + if (!(Test-AgentBaseDetected $parts[0] $base)) { continue } $dest = Join-Path $base $SkillName - if (Test-Path $dest) { Remove-Item -Recurse -Force $dest } - New-Item -ItemType Directory -Path $dest -Force | Out-Null - Copy-Item -Path "$src\*" -Destination $dest -Recurse -Force - $installed++; $idx++ + if ($universal) { + # Cleanup-only migration: universal Agents read the + # canonical store directly, so nothing is installed here. A + # retire failure only leaves an obsolete copy behind and + # must never fail an otherwise complete install. + try { Backup-DevSkill $dest } catch { + Say "⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): $dest ($($_.Exception.Message))" + $retireFailed++ + } + continue + } + # Per-agent degrade like install.sh: a failed link→copy fallback + # skips that agent loudly instead of aborting the whole loop. + try { + New-Item -ItemType Directory -Path $base -Force -ErrorAction Stop | Out-Null + try { Publish-DevSkillJunction $canonical $dest } + catch { + # A failed rollback leaves the old backup as the only + # trusted copy. Do not run a second publisher over that + # state; surface the failure and continue other agents. + if ($_.Exception.Data["DWSRollbackFailed"]) { throw } + Publish-DevSkillCopy $canonical $dest + } + } catch { + Say "⚠️ Agent Skill 目标安装失败,已跳过: $dest ($($_.Exception.Message))" + $failed++ + continue + } + $installed++ + } + if ($failed -gt 0) { Die "有 $failed 个 Agent 目标安装 $SkillName 失败(其余目标已安装)" } + if ($retireFailed -gt 0) { + Say "⚠️ 有 $retireFailed 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)" } Say "Skill dingtalk-misc -> $installed agent dir(s)" } else { Say "(dingtalk-misc not found in skills bundle; skipped)" } } catch { - Say "(skill install skipped: $($_.Exception.Message))" + Die "Skill install failed: $($_.Exception.Message)" } } diff --git a/scripts/install-devapp.sh b/scripts/install-devapp.sh index 1e7c5d7f..b7afc315 100755 --- a/scripts/install-devapp.sh +++ b/scripts/install-devapp.sh @@ -26,6 +26,448 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ❌ %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "✅ SHA256 checksum verified: ${name}" +} + + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + +copy_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. + if mkdir "$dest" 2>/dev/null; then + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" + return 0 + fi + retract_published_children "$stage" "$dest" "$manifest" + rmdir "$dest" 2>/dev/null || true + fi + discard_stage "$stage" + rm -f "$manifest" + if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 + if mkdir "$dest" 2>/dev/null; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then + rmdir "$backup" 2>/dev/null || true + else + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + rm -f "$restore_manifest" + fi + return 1 +} + +# refresh_cache_tree +# Ephemeral ~/.dws/skills cache refresh, identical to install-event.sh. The tree +# is byte-reproducible from the release asset, so it is never archived into +# ~/.dws/skill-backups; unlike the previous `rm -rf` + `cp` it only deletes the +# old cache after the replacement is fully staged, so an interrupted install can +# no longer leave a half-copied cache behind. +refresh_cache_tree() { + src="$1"; dest="$2"; parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. + prune_skill_backups + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp"; backup="$backup_root/$name"; i=0 + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done + record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 + mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi + return 1 + } + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. +SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) + done +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + # Create the link directly at the destination: symlink(2) refuses an + # occupied path with EEXIST, so publication itself is the atomic no-replace + # check (`mv` after the backup could still replace a file or symlink a + # concurrent writer created in between). A directory that slipped in turns + # `ln -s` into a container: remove exactly our nested link and roll back. + if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then + rm -rf "$stage" + return 0 + fi + nested="$dest/${relative##*/}" + if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then + rm -f "$nested" || true + fi + rm -rf "$stage" + if [ -n "$backup" ]; then + if [ -e "$dest" ] || [ -L "$dest" ]; then + printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 + elif ! mv "$backup" "$dest"; then + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + fi + return 1 +} + need_cmd curl detect_os() { @@ -81,20 +523,43 @@ install_skill() { # cache so `dws skill setup --mode multi` can find a source later cache="$HOME/.dws/skills/multi/$SKILL_NAME" - rm -rf "$cache"; mkdir -p "$cache"; cp -R "$src/." "$cache/" + refresh_cache_tree "$src" "$cache" || { printf ' ⚠️ Skill 缓存刷新失败: %s\n' "$cache" >&2; return 1; } - installed=0; idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then idx=$((idx + 1)); continue; fi - dest="$base/$SKILL_NAME"; rm -rf "$dest"; mkdir -p "$dest"; cp -R "$src/." "$dest/" - installed=$((installed + 1)); idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$SKILL_NAME" + copy_tree "$src" "$canonical" || return 1 + installed=1 + failed=0 + retire_failed=0 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base" || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. + if ! backup_skill_dir "$base/$SKILL_NAME" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$SKILL_NAME" >&2 + retire_failed=$((retire_failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$SKILL_NAME"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$SKILL_NAME" >&2 + failed=$((failed + 1)) + continue + fi + installed=$((installed + 1)) done + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$SKILL_NAME" >&2 + return 1 + fi say "✅ Skill dingtalk-misc → ${installed} agent dir(s)" } @@ -119,6 +584,7 @@ main() { say "⬇ Downloading ${asset} ..." curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed — does release ${DEVAPP_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then need_cmd unzip; unzip -q "$tmp/$asset" -d "$tmp" else @@ -132,10 +598,11 @@ main() { # 2) dev skill from the release's skills bundle if [ "$NO_SKILLS" != "1" ]; then if curl -fsSL "https://github.com/${DEVAPP_REPO}/releases/download/${DEVAPP_VERSION}/dws-skills.zip" -o "$tmp/skills.zip" 2>/dev/null; then + verify_release_asset "dws-skills.zip" "$tmp/skills.zip" mkdir -p "$tmp/sk" if command -v unzip >/dev/null 2>&1; then unzip -q "$tmp/skills.zip" -d "$tmp/sk"; else tar -xf "$tmp/skills.zip" -C "$tmp/sk"; fi say "" - install_skill "$tmp/sk" + install_skill "$tmp/sk" || err "dingtalk-misc Skill 安装失败,详见上方告警" else say " (no dws-skills.zip in release ${DEVAPP_VERSION}; skill skipped)" fi diff --git a/scripts/install-event.sh b/scripts/install-event.sh index 3402a333..aa3716cf 100755 --- a/scripts/install-event.sh +++ b/scripts/install-event.sh @@ -39,6 +39,28 @@ say() { printf ' %s\n' "$@"; } err() { printf ' ERROR: %s\n' "$@" >&2; exit 1; } need_cmd() { command -v "$1" >/dev/null 2>&1 || err "Missing required command: $1"; } +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then shasum -a 256 "$1" | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then openssl dgst -sha256 "$1" | awk '{print $NF}' + else return 1 + fi +} + +verify_release_asset() { + name="$1"; file="$2" + checksums="$tmp/checksums.txt" + if [ ! -f "$checksums" ]; then + curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/checksums.txt" -o "$checksums" \ + || err "Could not download checksums.txt; refusing unverified release assets." + fi + expected="$(awk -v asset="$name" '$2 == asset {print $1; exit}' "$checksums")" + [ -n "$expected" ] || err "${name} is missing from checksums.txt." + actual="$(sha256_file "$file")" || err "Could not compute SHA256 for ${name}." + [ "$actual" = "$expected" ] || err "SHA256 checksum mismatch for ${name}." + say "SHA256 checksum verified: ${name}" +} + detect_os() { case "$(uname -s)" in Linux*) echo linux ;; @@ -89,39 +111,467 @@ resolve_event_version() { [ -n "$EVENT_VERSION" ] || err "No stable release found on ${EVENT_REPO}. Set EVENT_VERSION to a published release tag." } + +# perm_of +# Prints the octal permission bits of path. Linux stat and BSD/macOS stat +# disagree on flags, so try both spellings and fail loudly when neither is +# available. +perm_of() { + _po_mode="$(stat -c %a "$1" 2>/dev/null || stat -f %Lp "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_po_mode" +} + +# discard_stage +# Best-effort removal of our own staging tree. Read-only directories copied +# from the skill bundle resist rm -rf, so grant owner write access first. +discard_stage() { + chmod -R u+w "$1" 2>/dev/null || true + rm -rf "$1" 2>/dev/null || true +} + +# count_tree_entries +count_tree_entries() { + _cte_n=0 + for _cte_e in "$1"/* "$1"/.[!.]* "$1"/..?*; do + [ -e "$_cte_e" ] || [ -L "$_cte_e" ] || continue + _cte_n=$((_cte_n+1)) + done + printf '%s\n' "$_cte_n" +} + +# publish_tree_children +# Publishes every child of the staging directory into the claimed dest through +# an atomic no-clobber primitive: directories claim with mkdir and recurse, +# regular files publish via ln (a hard link is a single atomic rename-class +# syscall that fails with EEXIST when the target exists) and then drop the +# staging source, and symlinks are recreated with ln -s. A POSIX `mv` cannot +# be used here: renaming a directory onto a concurrently created same-name +# (empty) directory replaces it, and mv offers no kernel-level no-replace. +# Every primitive therefore refuses a concurrently created object instead of +# replacing it. The absolute destination path of each published entry is +# appended to the manifest so the rollback only ever retracts entries this +# transaction published. The recursion runs in a subshell so the caller's +# loop variables survive; manifest appends and filesystem effects persist. +# After each level the destination is re-counted: more entries than the +# staging side had means a different-named concurrent writer landed +# mid-publish, and the whole publication aborts with the destination kept. +publish_tree_children() { + _ptc_stage="$1"; _ptc_dest="$2"; _ptc_manifest="$3" + _ptc_staged="$(count_tree_entries "$_ptc_stage")" + for _ptc_child in "$_ptc_stage"/* "$_ptc_stage"/.[!.]* "$_ptc_stage"/..?*; do + [ -e "$_ptc_child" ] || [ -L "$_ptc_child" ] || continue + _ptc_name="$(basename "$_ptc_child")" + _ptc_target="${_ptc_dest%/}/$_ptc_name" + if [ -L "$_ptc_child" ]; then + _ptc_link="$(readlink "$_ptc_child")" || return 1 + ln -s "$_ptc_link" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + elif [ -d "$_ptc_child" ]; then + _ptc_mode="$(perm_of "$_ptc_child")" || return 1 + # The staging tree is ours alone: skill trees ship read-only + # directories (0555), which would block unlinking the children and + # removing the emptied shell. Grant owner write access for the move; + # the published target receives the recorded mode below. + chmod u+w "$_ptc_child" || return 1 + mkdir "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + ( publish_tree_children "$_ptc_child" "$_ptc_target" "$_ptc_manifest" ) || return 1 + chmod "$_ptc_mode" "$_ptc_target" || return 1 + rmdir "$_ptc_child" 2>/dev/null || return 1 + else + ln "$_ptc_child" "$_ptc_target" 2>/dev/null || { + [ -e "$_ptc_target" ] || [ -L "$_ptc_target" ] || return 1 + printf ' 并发目录项已保留,拒绝覆盖 %s\n' "$_ptc_target" >&2 + return 1 + } + rm -f "$_ptc_child" || return 1 + fi + printf '%s\n' "$_ptc_target" >> "$_ptc_manifest" + done + _ptc_live="$(count_tree_entries "$_ptc_dest")" + if [ "$_ptc_live" -gt "$_ptc_staged" ]; then + printf ' 目标出现并发目录项,中止发布并保留 %s\n' "$_ptc_dest" >&2 + return 1 + fi + return 0 +} + +# retract_published_children +# Moves the manifest-recorded entries back into the staging tree in reverse +# publication order, so deeper entries move before (or together with, when a +# containing directory retraction carries them) the directories that hold +# them. Only entries this transaction published are touched; a concurrently +# created object is never retracted. An entry that no longer exists was +# carried away with an ancestor; a genuine move failure stops the retraction +# so the caller reports the retained destination. +retract_published_children() { + _rpc_stage_root="$1"; _rpc_dest_root="$2"; _rpc_manifest="$3" + [ -s "$_rpc_manifest" ] || return 0 + _rpc_rev="${_rpc_manifest}.rev" + sed '1!G;h;$!d' "$_rpc_manifest" > "$_rpc_rev" || { rm -f "$_rpc_rev"; return 1; } + while IFS= read -r _rpc_entry; do + [ -n "$_rpc_entry" ] || continue + _rpc_rel="${_rpc_entry#"${_rpc_dest_root%/}/"}" + _rpc_back="${_rpc_stage_root%/}/$_rpc_rel" + mkdir -p "$(dirname "$_rpc_back")" 2>/dev/null || break + rmdir "$_rpc_back" 2>/dev/null || true + mv "$_rpc_entry" "$_rpc_back" 2>/dev/null || { + [ -e "$_rpc_entry" ] || [ -L "$_rpc_entry" ] || continue + break + } + done < "$_rpc_rev" + rm -f "$_rpc_rev" + return 0 +} + copy_tree() { src="$1" dest="$2" - rm -rf "$dest" - mkdir -p "$dest" - cp -R "$src/." "$dest/" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-skill.tmp.XXXXXX")" || return 1 + manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || { rm -rf "$stage"; return 1; } + if ! cp -R "$src/." "$stage/"; then discard_stage "$stage"; rm -f "$manifest"; return 1; fi + chmod u+w "$stage" 2>/dev/null || true + backup="$(backup_skill_dir "$dest")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + _ct_mode="$(perm_of "$src")" || { discard_stage "$stage"; rm -f "$manifest"; return 1; } + # mkdir is the atomic no-replace claim: it fails when the path is already + # occupied, and the claim is then held for the whole transaction. Children + # publish into the claim through per-entry no-clobber primitives (see + # publish_tree_children) so a concurrent writer can never be replaced; the + # rollback only retracts what the manifest recorded, and the claim itself + # is removed only while still empty, so foreign entries always survive. + if mkdir "$dest" 2>/dev/null; then + if publish_tree_children "$stage" "$dest" "$manifest"; then + chmod "$_ct_mode" "$dest" 2>/dev/null || true + discard_stage "$stage" + rm -f "$manifest" + return 0 + fi + retract_published_children "$stage" "$dest" "$manifest" + rmdir "$dest" 2>/dev/null || true + fi + discard_stage "$stage" + rm -f "$manifest" + if [ -n "$backup" ]; then + restore_manifest="$(mktemp "$parent/.dws-skill.txn.XXXXXX")" || return 1 + if mkdir "$dest" 2>/dev/null; then + # The restore publishes the backup through the same no-clobber + # discipline: a concurrent writer that took the emptied path (or any + # entry inside it) is refused and the backup is kept intact instead + # of being partially drained by an overwriting move. + if publish_tree_children "$backup" "$dest" "$restore_manifest"; then + rmdir "$backup" 2>/dev/null || true + else + retract_published_children "$backup" "$dest" "$restore_manifest" + rmdir "$dest" 2>/dev/null || true + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + else + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + rm -f "$restore_manifest" + fi + return 1 +} + +# refresh_cache_tree +# Same staged, atomic swap as copy_tree, but for the ephemeral ~/.dws/skills +# caches: those trees are byte-reproducible from the release asset, so archiving +# every stale generation into ~/.dws/skill-backups only grows that directory +# without bound. The previous cache is deleted, exactly like install-devapp.sh — +# but only after the replacement is fully staged and published. +refresh_cache_tree() { + src="$1" + dest="$2" + parent="$(dirname "$dest")" + mkdir -p "$parent" || return 1 + stage="$(mktemp -d "$parent/.dws-cache.tmp.XXXXXX")" || return 1 + if ! cp -R "$src/." "$stage/"; then rm -rf "$stage"; return 1; fi + old="" + if [ -e "$dest" ] || [ -L "$dest" ]; then + old="$(mktemp -d "$parent/.dws-cache.old.XXXXXX")" || { rm -rf "$stage"; return 1; } + rmdir "$old" || { rm -rf "$stage" "$old"; return 1; } + if ! mv "$dest" "$old"; then rm -rf "$stage"; return 1; fi + fi + if mv "$stage" "$dest"; then + if [ -n "$old" ] && ! rm -rf "$old"; then + printf ' ⚠️ 新缓存已生效,但旧缓存清理失败: %s\n' "$old" >&2 + fi + return 0 + fi + rm -rf "$stage" + if [ -n "$old" ] && ! mv "$old" "$dest"; then + printf ' ⚠️ 缓存刷新失败,原缓存保留在 %s\n' "$old" >&2 + fi + return 1 +} + +backup_skill_dir() { + victim="$1" + [ -e "$victim" ] || [ -L "$victim" ] || { printf '\n'; return 0; } + # Prune before creating this run's archive; every stamp this run has already + # created is registered, so pruning can only remove earlier-run batches. + prune_skill_backups + stamp="$(date -u +%Y%m%d-%H%M%S)" + name="$(printf '%s' "$victim" | sed 's#[/\\]#-#g; s#^-##')" + backup_root="$HOME/.dws/skill-backups/$stamp" + backup="$backup_root/$name" + i=0 + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$backup" ] || [ -L "$backup" ] || + { [ -d "$backup_root" ] && ! is_current_run_backup_stamp "$backup_root" && + [ "$(cat "$backup_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + i=$((i + 1)); backup_root="$HOME/.dws/skill-backups/$stamp-$i"; backup="$backup_root/$name" + # Same bail-out as install-skills.sh: never spin forever on a pathological + # backup root, report it and keep the original directory instead. + if [ "$i" -gt 1000 ]; then + printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$victim" >&2 + return 1 + fi + done + record_current_run_backup_stamp "$backup_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + fresh=1 + [ ! -d "$backup_root" ] || fresh=0 + mkdir -p "$backup_root" || return 1 + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before the payload moves in. + printf '%s\n' 'dws skill backup v1' > "$backup_root/.dws-skill-backup" || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$fresh" -eq 1 ]; then + rm -f "$backup_root/.dws-skill-backup" + rmdir "$backup_root" 2>/dev/null || true + fi + return 1 + } + mv "$victim" "$backup" || return 1 + printf '%s\n' "$backup" +} + +# prune_skill_backups keeps only the newest SKILL_BACKUP_KEEP stamped backup +# directories from earlier runs under $HOME/.dws/skill-backups, matching Go's +# skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots carrying the +# .dws-skill-backup marker with the exact expected content are counted or +# removed: foreign data with a stamp-like name never consumes a keep slot +# and is never deleted, silently. Stamp directories created by the +# current process are never pruned (mirroring Go's run-root registry and +# install.js's currentRunBackupRoots), so a migration that retires more than +# SKILL_BACKUP_KEEP batches stays reversible. Glob order is lexicographic and +# the stamps are UTC YYYYmmdd-HHMMSS, so the oldest sort first. Best-effort: +# a removal failure is reported and never fatal. +SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by backup_skill_dir so +# pruning can never delete a backup the running migration may still need to +# roll back to. +CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + CURRENT_RUN_BACKUP_STAMPS="${CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + prune_root="$HOME/.dws/skill-backups" + [ -d "$prune_root" ] || return 0 + prune_total=0 + for prune_entry in "$prune_root"/*; do + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + prune_total=$((prune_total + 1)) + done + [ "$prune_total" -gt "$SKILL_BACKUP_KEEP" ] || return 0 + prune_drop=$((prune_total - SKILL_BACKUP_KEEP)) + for prune_entry in "$prune_root"/*; do + [ "$prune_drop" -gt 0 ] || break + [ -d "$prune_entry" ] || continue + is_skill_backup_stamp "${prune_entry##*/}" || continue + [ "$(cat "$prune_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $CURRENT_RUN_BACKUP_STAMPS " in + *" $prune_entry "*) continue ;; + esac + rm -rf "$prune_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$prune_entry" >&2 + prune_drop=$((prune_drop - 1)) + done +} + +same_physical_skill() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$left" = "$right" ] +} + +is_cleanup_only_agent_dir() { + case "$1" in + .config/agents/skills|.gemini/antigravity/skills|.gemini/antigravity-cli/skills|.codex/skills|.cursor/skills|.deepagents/agent/skills|.firebender/skills|.gemini/skills|.copilot/skills|.config/opencode/skills|.github/skills|.windsurf/skills|.cline/skills|.amp/skills) return 0 ;; + *) return 1 ;; + esac +} + +agent_skill_dirs() { + printf '%s\n' \ + .config/agents/skills .gemini/antigravity/skills .gemini/antigravity-cli/skills .codex/skills .cursor/skills .deepagents/agent/skills .firebender/skills .gemini/skills .copilot/skills .config/opencode/skills \ + .aider-desk/skills .astrbot/data/skills .autohand/skills .augment/skills .bob/skills .claude/skills .openclaw/skills .codeartsdoer/skills .codebuddy/skills .codemaker/skills .codestudio/skills .commandcode/skills .continue/skills .snowflake/cortex/skills .config/crush/skills .config/devin/skills .factory/skills .forge/skills .config/goose/skills .grok/skills .hermes/skills .inferencesh/skills .jazz/skills .junie/skills .iflow/skills .kilocode/skills .config/kimchi/harness/skills .kiro/skills .kode/skills .lingma/skills .mcpjam/skills .minimax/skills .vibe/skills .moxby/skills .mux/skills .openhands/skills .ona/skills .pi/agent/skills .qoder/skills .qoder-cn/skills .qwen/skills .reasonix/skills .rovodev/skills .roo/skills .tabnine/agent/skills .terramind/skills .tinycloud/skills .trae/skills .trae-cn/skills .codeium/windsurf/skills .zcode/skills .zencoder/skills .neovate/skills .pochi/skills .adal/skills \ + .qoderwork/skills .github/skills .windsurf/skills .cline/skills .amp/skills +} + +resolve_agent_skill_base() { + agent_dir="$1"; base="$HOME/$agent_dir" + case "$agent_dir" in + .claude/skills) [ -n "${CLAUDE_CONFIG_DIR:-}" ] && base="$CLAUDE_CONFIG_DIR/skills" ;; + .codex/skills) [ -n "${CODEX_HOME:-}" ] && base="$CODEX_HOME/skills" ;; + .hermes/skills) [ -n "${HERMES_HOME:-}" ] && base="$HERMES_HOME/skills" ;; + .autohand/skills) [ -n "${AUTOHAND_HOME:-}" ] && base="$AUTOHAND_HOME/skills" ;; + .grok/skills) [ -n "${GROK_HOME:-}" ] && base="$GROK_HOME/skills" ;; + .vibe/skills) [ -n "${VIBE_HOME:-}" ] && base="$VIBE_HOME/skills" ;; + .openclaw/skills) for legacy in .openclaw .clawdbot .moltbot; do [ -d "$HOME/$legacy" ] && { base="$HOME/$legacy/skills"; break; }; done ;; + .config/*) base="${XDG_CONFIG_HOME:-$HOME/.config}/${agent_dir#.config/}" ;; + esac + printf '%s\n' "$base" +} + +agent_skill_base_detected() { + agent_dir="$1"; base="$2" + case "$agent_dir" in + .config/kimchi/harness/skills|.tabnine/agent/skills) [ -d "$(dirname "$(dirname "$base")")" ] ;; + .zcode/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/ZCode.app" ] ;; + .minimax/skills) [ -d "$(dirname "$base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$base")" ] ;; + esac +} + +link_or_copy_skill() { + canonical="$1"; src="$2"; dest="$3" + same_physical_skill "$dest" "$canonical" && return 0 + parent="$(dirname "$dest")"; mkdir -p "$parent" || return 1 + parent_real="$(CDPATH= cd -- "$parent" && pwd -P)" || return 1 + target_real="$(CDPATH= cd -- "$canonical" && pwd -P)" || return 1 + relative="$(awk -v from="$parent_real" -v to="$target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null; then + rm -rf "$stage" + if copy_tree "$src" "$dest"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$dest" >&2 + return 0 + fi + return 1 + fi + backup="$(backup_skill_dir "$dest")" || { rm -rf "$stage"; return 1; } + # Create the link directly at the destination: symlink(2) refuses an + # occupied path with EEXIST, so publication itself is the atomic no-replace + # check (`mv` after the backup could still replace a file or symlink a + # concurrent writer created in between). A directory that slipped in turns + # `ln -s` into a container: remove exactly our nested link and roll back. + if ln -s "$relative" "$dest" 2>/dev/null && [ -L "$dest" ]; then + rm -rf "$stage" + return 0 + fi + nested="$dest/${relative##*/}" + if [ -L "$nested" ] && [ "$(readlink "$nested" 2>/dev/null)" = "$relative" ]; then + rm -f "$nested" || true + fi + rm -rf "$stage" + if [ -n "$backup" ]; then + if [ -e "$dest" ] || [ -L "$dest" ]; then + printf ' ⚠️ %s 在发布期间被并发写入占用,原 Skill 保留于备份: %s\n' "$dest" "$backup" >&2 + elif ! mv "$backup" "$dest"; then + printf ' Skill rollback failed; backup retained at %s\n' "$backup" >&2 + fi + fi + return 1 } install_skill_to_homes() { src="$1" skill_name="$2" - - installed=0 - idx=0 - for agent_dir in \ - .agents/skills .claude/skills .cursor/skills .qoder/skills .qoderwork/skills \ - .gemini/skills .codex/skills .github/skills .windsurf/skills .augment/skills \ - .cline/skills .amp/skills .kiro/skills .trae/skills .openclaw/skills .hermes/skills \ - .config/opencode/skills - do - base="$HOME/$agent_dir" - if [ "$idx" -gt 0 ] && [ ! -e "$(dirname "$base")" ]; then - idx=$((idx + 1)) + canonical="$HOME/.agents/skills/$skill_name" + copy_tree "$src" "$canonical" || return 1 + installed=1 + failed=0 + retire_failed=0 + for agent_dir in $(agent_skill_dirs); do + base="$(resolve_agent_skill_base "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base" || continue + same_physical_skill "$base" "$HOME/.agents/skills" && continue + if is_cleanup_only_agent_dir "$agent_dir"; then + # Cleanup-only: universal Agents read the canonical store directly, so + # nothing is installed here. A retire failure only leaves an obsolete copy + # behind and must never fail an otherwise complete install. + if ! backup_skill_dir "$base/$skill_name" >/dev/null; then + printf ' ⚠️ Agent Skill 旧副本备份失败,保留原目录(不影响本次安装): %s\n' "$base/$skill_name" >&2 + retire_failed=$((retire_failed + 1)) + fi + continue + fi + # Per-agent degrade like install.sh: a failed target is reported and + # skipped loudly instead of aborting the remaining agents mid-loop. + if ! link_or_copy_skill "$canonical" "$src" "$base/$skill_name"; then + printf ' ⚠️ Agent 目标安装失败,已跳过: %s\n' "$base/$skill_name" >&2 + failed=$((failed + 1)) continue fi - copy_tree "$src" "$base/$skill_name" installed=$((installed + 1)) - idx=$((idx + 1)) done - - if [ "$installed" -eq 0 ]; then - copy_tree "$src" "$HOME/.agents/skills/$skill_name" - installed=1 + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" >&2 + fi + if [ "$failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 目标安装 %s 失败\n' "$failed" "$skill_name" >&2 + return 1 fi printf '%s\n' "$installed" } @@ -176,17 +626,21 @@ install_skills_from_bundle() { event_cache="$HOME/.dws/skills/multi/$EVENT_SKILL_NAME" shared_cache="$HOME/.dws/skills/multi/$SHARED_SKILL_NAME" misc_cache="$HOME/.dws/skills/multi/$MISC_SKILL_NAME" - copy_tree "$event_src" "$event_cache" - copy_tree "$shared_src" "$shared_cache" - copy_tree "$misc_src" "$misc_cache" + refresh_cache_tree "$event_src" "$event_cache" || err "${EVENT_SKILL_NAME} 缓存刷新失败: ${event_cache}" + refresh_cache_tree "$shared_src" "$shared_cache" || err "${SHARED_SKILL_NAME} 缓存刷新失败: ${shared_cache}" + refresh_cache_tree "$misc_src" "$misc_cache" || err "${MISC_SKILL_NAME} 缓存刷新失败: ${misc_cache}" mono_cache="$HOME/.dws/skills/mono" - copy_tree "$mono_src" "$mono_cache" + refresh_cache_tree "$mono_src" "$mono_cache" || err "mono ${MONO_SKILL_NAME} 缓存刷新失败: ${mono_cache}" - event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" - shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" - misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" - mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" + event_installed="$(install_skill_to_homes "$event_src" "$EVENT_SKILL_NAME")" \ + || err "${EVENT_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + shared_installed="$(install_skill_to_homes "$shared_src" "$SHARED_SKILL_NAME")" \ + || err "${SHARED_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + misc_installed="$(install_skill_to_homes "$misc_src" "$MISC_SKILL_NAME")" \ + || err "${MISC_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" + mono_installed="$(install_skill_to_homes "$mono_src" "$MONO_SKILL_NAME")" \ + || err "mono ${MONO_SKILL_NAME} 分发到 Agent 目录失败,详见上方告警" say "Skill ${EVENT_SKILL_NAME} -> ${event_installed} agent dir(s)" say "Skill ${SHARED_SKILL_NAME} -> ${shared_installed} agent dir(s)" @@ -212,6 +666,7 @@ install_binary() { say "Downloading ${asset} ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/${asset}" -o "$tmp/$asset" \ || err "Binary download failed. Does release ${EVENT_VERSION} have ${asset}?" + verify_release_asset "$asset" "$tmp/$asset" if [ "$os" = "windows" ]; then extract_zip "$tmp/$asset" "$tmp/bin" @@ -240,6 +695,7 @@ install_skills() { say "Downloading dws-skills.zip ..." curl -fsSL "https://github.com/${EVENT_REPO}/releases/download/${EVENT_VERSION}/dws-skills.zip" -o "$tmp/dws-skills.zip" \ || err "dws-skills.zip download failed for release ${EVENT_VERSION}" + verify_release_asset "dws-skills.zip" "$tmp/dws-skills.zip" mkdir -p "$tmp/skills" extract_zip "$tmp/dws-skills.zip" "$tmp/skills" diff --git a/scripts/install-skills.sh b/scripts/install-skills.sh index ad4eeb03..77ad6145 100755 --- a/scripts/install-skills.sh +++ b/scripts/install-skills.sh @@ -53,24 +53,54 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then printf ' ⚠️ 备份目录冲突,保留原目录 %s\n' "$_bed_dir" return 1 fi done - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 + mkdir -p "$_bed_stamp_root" 2>/dev/null || { printf ' ⚠️ 无法创建备份目录,保留原目录 %s\n' "$_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi + printf ' ⚠️ 无法写入备份所有权标记,保留原目录 %s\n' "$_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" printf ' × 已备份并移除 %s → %s\n' "$_bed_dir" "$_bed_target" @@ -80,6 +110,88 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. +DWS_SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac + rm -rf "$_psb_entry" || printf ' ⚠️ 旧 Skill 备份清理失败: %s\n' "$_psb_entry" + _psb_drop=$((_psb_drop - 1)) + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -123,6 +235,46 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. +sha256_file() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif command -v openssl >/dev/null 2>&1; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || { printf '❌ Could not resolve checksums.txt for %s.\n' "$VERSION" >&2; return 1; } + curl -fsSL "$_checksum_url" -o "$_checksum_dir/checksums.txt" || { + printf '❌ Could not download checksums.txt for %s; refusing unverified %s.\n' "$VERSION" "$_checksum_asset" >&2 + return 1 + } + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || { printf '❌ %s is missing from checksums.txt.\n' "$_checksum_asset" >&2; return 1; } + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || { + printf '❌ Could not compute SHA256 for %s; install sha256sum, shasum, or openssl.\n' "$_checksum_asset" >&2 + return 1 + } + [ "$_checksum_actual" = "$_checksum_expected" ] || { + printf '❌ SHA256 checksum mismatch for %s. Expected %s, got %s.\n' "$_checksum_asset" "$_checksum_expected" "$_checksum_actual" >&2 + return 1 + } + printf ' ✅ SHA256 checksum verified: %s\n' "$_checksum_asset" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -189,24 +341,147 @@ backup_and_record_skill_dir() { fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + printf ' ⚠️ 无法隔离待回滚 Skill: %s\n' "$_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s\n' "$_qrr_dest" + return 1 + fi + printf ' ⚠️ 跳过回滚已被并发修改的 Skill 路径: %s(并发对象保留于 %s)\n' "$_qrr_dest" "$_qrr_payload" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported +# its exact backup path. Dest is claimed into quarantine first; identity is +# re-checked there so a concurrent replacement is renamed back instead of +# being deleted by path. Paths containing newlines are outside the supported # installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { printf ' ⚠️ 跳过格式异常的发布记录: %s\n' "$_rms_entry"; _rms_ok=0; continue; } + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then printf ' ⚠️ 无法恢复原 Skill: %s(备份保留于 %s)\n' "$_rms_original" "$_rms_backup" _rms_ok=0 fi @@ -215,6 +490,135 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree, mirroring the Go +# fingerprint: sorted relative paths emit type+permission bits plus either a +# content sha256 or the symlink target, and the whole line stream feeds +# sha256_stdin. Failure detection is asymmetric and best-effort: with no hash +# tool installed sha256_stdin itself returns 1 and the identity genuinely +# fails, but an unreadable file only exits the left-hand subshell — /bin/sh +# has no pipefail, so the pipeline returns sha256_stdin's status and the +# digest is computed over a truncated stream instead of failing. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then + rm -f "$_cnsl_nested" + fi +} + +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Stages a complete sibling cache before publishing it. Any copy or publish # failure leaves the previous cache in place (or in the reported recovery dir @@ -408,16 +812,99 @@ multi_tree_has_skills() { return 1 } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering duplicates. -retire_generic_skill_root() { +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots, in pinned registry order, de-duplicated. Records +# without a global root (`-`) and canonical-direct records (`.agents/skills`) +# are not per-Agent targets and are skipped. +agent_skill_dirs() { + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -430,76 +917,162 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. +link_canonical_skills_to_base() { + _lcs_root="$1"; _lcs_base="$2"; _lcs_mode="$3"; _lcs_bundle="${4-}" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + printf ' ⚠️ %s 已存在且不是 DWS 安装的 Skill,无法在此建立指向 %s 的共享链接\n' "$_lcs_dest" "$_lcs_canonical/$_lcs_name" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + printf ' ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;\n' + printf ' 移走或改名后重新运行安装即可切换为共享 %s 布局。\n' "$_lcs_canonical" + return 1 + fi + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups"; _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then continue; fi + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + # Publish by creating the link directly at the destination: symlink(2) + # refuses an occupied path with EEXIST, so the creation itself is the + # atomic no-replace check. `mv` after any pre-check could still replace a + # file or symlink a concurrent writer created in between, and `ln -P` + # stays unusable (absent from BusyBox `ln`, which silently degraded every + # non-universal Agent to the copy layout on Alpine and most containers). + if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + # A directory that appeared at the destination turns `ln -s` into a + # container: the link lands inside it under the target basename. Remove + # exactly our link after an identity check and roll back; the foreign + # directory stays untouched. + if [ ! -L "$_lcs_dest" ]; then + _lcs_nested_name="${_lcs_link_target##*/}" + _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode="" + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + } + if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true; rm -rf "$_lcs_stage"; return 1 + fi + printf ' ↪ Skills → %s\n' "$_lcs_dest" + done + rm -rf "$_lcs_stage" +} + # Same semantics as build/npm/install.js installMultiSkillsToHomes (root = DWS_SKILLS_ROOT or PWD). install_multi_skills_to_root() { multi_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n'; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else - failed=$((failed + 1)) - printf ' ⚠️ 跳过 %s(备份或复制失败,未完成 multi 安装)\n' "$base_dir" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装失败\n' "$failed" return 1 fi write_skills_state "$multi_src" "install-skills.sh" || return 1 + printf ' ✅ DWS Skills 安装完成\n' + printf ' 统一安装位置:%s/.agents/skills\n' "$root" + printf ' 已自动适配本机上检测到的 Agent\n' + printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } _install_multi_to_base() { @@ -574,12 +1147,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then printf ' ⚠️ multi Skill 集合发布失败,正在恢复原集合: %s\n' "$_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_ms_stage" @@ -633,12 +1201,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then printf ' ⚠️ mono Skill 集合发布失败,正在恢复原集合: %s\n' "$_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || printf ' ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径\n' rm -rf "$_mono_stage" @@ -654,84 +1217,48 @@ install_skills_to_root() { skill_src="$1" root="$2" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$root/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n'; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if [ "$root" = "$HOME" ]; then - label="~/$agent_dir/$SKILL_NAME" - else - label="$root/$agent_dir/$SKILL_NAME" + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue fi - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then + printf ' ℹ️ %s 已自动使用兼容方式安装,可正常使用\n' "$base_dir" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - if [ "$root" = "$HOME" ]; then - flabel="~/.agents/skills/$SKILL_NAME" - else - flabel="$root/.agents/skills/$SKILL_NAME" - fi - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then printf ' ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败\n' return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then printf ' ⚠️ 有 %s 个 Agent 目标安装 mono Skill 失败\n' "$failed" return 1 fi rm -f "$DWS_STATE_ROOT/skills-state.json" + printf ' ✅ DWS Skills 安装完成\n' + printf ' 统一安装位置:%s/.agents/skills\n' "$root" + printf ' 已自动适配本机上检测到的 Agent\n' + printf ' ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效\n' } # ── Main ───────────────────────────────────────────────────────────────────── @@ -755,6 +1282,7 @@ main() { [ -n "$ASSET_URL" ] || { printf '❌ Could not resolve download URL for dws-skills.zip (version %s).\n' "$VERSION" >&2; exit 1; } printf ' ⬇ Downloading skills from GitHub Releases: %s (%s)\n' "$REPO" "$VERSION" curl -fsSL "$ASSET_URL" -o "$TMPDIR_WORK/dws-skills.zip" + verify_release_asset_checksum "dws-skills.zip" "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK" extract_zip "$TMPDIR_WORK/dws-skills.zip" "$TMPDIR_WORK/extracted" # Prefer the explicit mono/ subtree; fall back to legacy nested or zip root. @@ -803,6 +1331,10 @@ main() { fi fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' printf ' 📖 Skill includes:\n' printf ' • SKILL.md — Main skill with product overview and intent routing\n' diff --git a/scripts/install.ps1 b/scripts/install.ps1 index d9c62a1b..93fdf168 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -52,27 +52,103 @@ $LegacyOfficialMultiSkills = @( "dingtalk-todo", "dingtalk-wiki", "dws-shared" ) -# Agent skill base directories (same order as build/npm/install.js AGENT_DIRS). -$AgentDirs = @( - ".agents\skills", - ".claude\skills", - ".cursor\skills", - ".qoder\skills", - ".qoderwork\skills", - ".gemini\skills", - ".codex\skills", - ".zcode\skills", - ".github\skills", - ".windsurf\skills", - ".augment\skills", - ".cline\skills", - ".amp\skills", - ".kiro\skills", - ".trae\skills", - ".openclaw\skills", - ".hermes\skills" +# Agent registry synchronized with vercel-labs/skills agents.ts (c6f69c6). +# Universal agents read the canonical store directly; their separate historical +# global directories are cleanup-only. Non-universal agents get junctions to +# canonical (or complete copies when junction creation is unavailable). +$AgentRegistry = @( + [pscustomobject]@{ Id = "aider-desk"; Universal = $false; Dir = ".aider-desk\skills" }, + [pscustomobject]@{ Id = "amp"; Universal = $true; Dir = ".config\agents\skills" }, + [pscustomobject]@{ Id = "antigravity"; Universal = $true; Dir = ".gemini\antigravity\skills" }, + [pscustomobject]@{ Id = "antigravity-cli"; Universal = $true; Dir = ".gemini\antigravity-cli\skills" }, + [pscustomobject]@{ Id = "astrbot"; Universal = $false; Dir = ".astrbot\data\skills" }, + [pscustomobject]@{ Id = "autohand-code"; Universal = $false; Dir = ".autohand\skills" }, + [pscustomobject]@{ Id = "augment"; Universal = $false; Dir = ".augment\skills" }, + [pscustomobject]@{ Id = "bob"; Universal = $false; Dir = ".bob\skills" }, + [pscustomobject]@{ Id = "claude-code"; Universal = $false; Dir = ".claude\skills" }, + [pscustomobject]@{ Id = "openclaw"; Universal = $false; Dir = ".openclaw\skills" }, + [pscustomobject]@{ Id = "cline"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "codearts-agent"; Universal = $false; Dir = ".codeartsdoer\skills" }, + [pscustomobject]@{ Id = "codebuddy"; Universal = $false; Dir = ".codebuddy\skills" }, + [pscustomobject]@{ Id = "codemaker"; Universal = $false; Dir = ".codemaker\skills" }, + [pscustomobject]@{ Id = "codestudio"; Universal = $false; Dir = ".codestudio\skills" }, + [pscustomobject]@{ Id = "codex"; Universal = $true; Dir = ".codex\skills" }, + [pscustomobject]@{ Id = "command-code"; Universal = $false; Dir = ".commandcode\skills" }, + [pscustomobject]@{ Id = "continue"; Universal = $false; Dir = ".continue\skills" }, + [pscustomobject]@{ Id = "cortex"; Universal = $false; Dir = ".snowflake\cortex\skills" }, + [pscustomobject]@{ Id = "crush"; Universal = $false; Dir = ".config\crush\skills" }, + [pscustomobject]@{ Id = "cursor"; Universal = $true; Dir = ".cursor\skills" }, + [pscustomobject]@{ Id = "deepagents"; Universal = $true; Dir = ".deepagents\agent\skills" }, + [pscustomobject]@{ Id = "devin"; Universal = $false; Dir = ".config\devin\skills" }, + [pscustomobject]@{ Id = "dexto"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "droid"; Universal = $false; Dir = ".factory\skills" }, + [pscustomobject]@{ Id = "eve"; Universal = $false; Dir = $null }, + [pscustomobject]@{ Id = "firebender"; Universal = $true; Dir = ".firebender\skills" }, + [pscustomobject]@{ Id = "forgecode"; Universal = $false; Dir = ".forge\skills" }, + [pscustomobject]@{ Id = "gemini-cli"; Universal = $true; Dir = ".gemini\skills" }, + [pscustomobject]@{ Id = "github-copilot"; Universal = $true; Dir = ".copilot\skills" }, + [pscustomobject]@{ Id = "goose"; Universal = $false; Dir = ".config\goose\skills" }, + [pscustomobject]@{ Id = "grok"; Universal = $false; Dir = ".grok\skills" }, + [pscustomobject]@{ Id = "hermes-agent"; Universal = $false; Dir = ".hermes\skills" }, + [pscustomobject]@{ Id = "inference-sh"; Universal = $false; Dir = ".inferencesh\skills" }, + [pscustomobject]@{ Id = "jazz"; Universal = $false; Dir = ".jazz\skills" }, + [pscustomobject]@{ Id = "junie"; Universal = $false; Dir = ".junie\skills" }, + [pscustomobject]@{ Id = "iflow-cli"; Universal = $false; Dir = ".iflow\skills" }, + [pscustomobject]@{ Id = "kilo"; Universal = $false; Dir = ".kilocode\skills" }, + [pscustomobject]@{ Id = "kimchi"; Universal = $false; Dir = ".config\kimchi\harness\skills" }, + [pscustomobject]@{ Id = "kimi-code-cli"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "kiro-cli"; Universal = $false; Dir = ".kiro\skills" }, + [pscustomobject]@{ Id = "kode"; Universal = $false; Dir = ".kode\skills" }, + [pscustomobject]@{ Id = "lingma"; Universal = $false; Dir = ".lingma\skills" }, + [pscustomobject]@{ Id = "loaf"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "mcpjam"; Universal = $false; Dir = ".mcpjam\skills" }, + [pscustomobject]@{ Id = "minimax-code"; Universal = $false; Dir = ".minimax\skills" }, + [pscustomobject]@{ Id = "mistral-vibe"; Universal = $false; Dir = ".vibe\skills" }, + [pscustomobject]@{ Id = "moxby"; Universal = $false; Dir = ".moxby\skills" }, + [pscustomobject]@{ Id = "mux"; Universal = $false; Dir = ".mux\skills" }, + [pscustomobject]@{ Id = "opencode"; Universal = $true; Dir = ".config\opencode\skills" }, + [pscustomobject]@{ Id = "openhands"; Universal = $false; Dir = ".openhands\skills" }, + [pscustomobject]@{ Id = "ona"; Universal = $false; Dir = ".ona\skills" }, + [pscustomobject]@{ Id = "pi"; Universal = $false; Dir = ".pi\agent\skills" }, + [pscustomobject]@{ Id = "qoder"; Universal = $false; Dir = ".qoder\skills" }, + [pscustomobject]@{ Id = "qoder-cn"; Universal = $false; Dir = ".qoder-cn\skills" }, + [pscustomobject]@{ Id = "qwen-code"; Universal = $false; Dir = ".qwen\skills" }, + [pscustomobject]@{ Id = "replit"; Universal = $true; Dir = ".config\agents\skills" }, + [pscustomobject]@{ Id = "reasonix"; Universal = $false; Dir = ".reasonix\skills" }, + [pscustomobject]@{ Id = "rovodev"; Universal = $false; Dir = ".rovodev\skills" }, + [pscustomobject]@{ Id = "roo"; Universal = $false; Dir = ".roo\skills" }, + [pscustomobject]@{ Id = "tabnine-cli"; Universal = $false; Dir = ".tabnine\agent\skills" }, + [pscustomobject]@{ Id = "terramind"; Universal = $false; Dir = ".terramind\skills" }, + [pscustomobject]@{ Id = "tinycloud"; Universal = $false; Dir = ".tinycloud\skills" }, + [pscustomobject]@{ Id = "trae"; Universal = $false; Dir = ".trae\skills" }, + [pscustomobject]@{ Id = "trae-cn"; Universal = $false; Dir = ".trae-cn\skills" }, + [pscustomobject]@{ Id = "warp"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "windsurf"; Universal = $false; Dir = ".codeium\windsurf\skills" }, + [pscustomobject]@{ Id = "zed"; Universal = $true; Dir = ".agents\skills" }, + [pscustomobject]@{ Id = "zcode"; Universal = $false; Dir = ".zcode\skills" }, + [pscustomobject]@{ Id = "zencoder"; Universal = $false; Dir = ".zencoder\skills" }, + [pscustomobject]@{ Id = "zenflow"; Universal = $false; Dir = ".zencoder\skills" }, + [pscustomobject]@{ Id = "neovate"; Universal = $false; Dir = ".neovate\skills" }, + [pscustomobject]@{ Id = "pochi"; Universal = $false; Dir = ".pochi\skills" }, + [pscustomobject]@{ Id = "promptscript"; Universal = $true; Dir = $null }, + [pscustomobject]@{ Id = "adal"; Universal = $false; Dir = ".adal\skills" }, + [pscustomobject]@{ Id = "universal"; Universal = $true; Dir = ".config\agents\skills" } ) +# DWS compatibility targets that are not part of the upstream registry. +# Qoderwork remains a non-universal install target; the other entries are +# migration cleanup targets only and intentionally do not count as agents. +$LegacyAgentCleanupTargets = @( + [pscustomobject]@{ Id = "dws-qoderwork"; Universal = $false; Dir = ".qoderwork\skills" }, + [pscustomobject]@{ Id = "dws-legacy-github"; Universal = $true; Dir = ".github\skills" }, + [pscustomobject]@{ Id = "dws-legacy-amp"; Universal = $true; Dir = ".amp\skills" }, + [pscustomobject]@{ Id = "dws-legacy-cline"; Universal = $true; Dir = ".cline\skills" }, + [pscustomobject]@{ Id = "dws-legacy-windsurf"; Universal = $true; Dir = ".windsurf\skills" } +) + +# Kept as a compatibility surface for policy tests and downstream packagers. +$AgentDirs = @($AgentRegistry | Where-Object { $null -ne $_.Dir } | ForEach-Object { $_.Dir }) + # ── Helpers ────────────────────────────────────────────────────────────────── function Write-Say { @@ -182,7 +258,255 @@ function Move-SkillPath { [string]$Source, [string]$Destination ) - Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (Test-SkillPathLexically -Path $Destination) { + throw "Skill move destination already exists: $Destination" + } + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($sourceItem.PSIsContainer) { + # Move-Item treats an existing directory as a container and nests the + # source below it. Directory.Move has the exact rename semantics this + # transaction requires: an occupied destination fails without touching + # either path. + [System.IO.Directory]::Move($Source, $Destination) + } else { + # File.Move replaces an occupied dest on Windows. Copy without + # overwrite refuses that dest, then the source is removed only after + # the exclusive copy succeeds. + [System.IO.File]::Copy($Source, $Destination, $false) + try { + Remove-SkillPathLexically -Path $Source + } catch { + $removeErr = $_ + try { + Remove-SkillPathLexically -Path $Destination + } catch { + throw "Skill move state uncertain; source $Source and dest $Destination retained: $removeErr; retract failed: $_" + } + throw $removeErr + } + } +} + +function Test-CrossDeviceMoveError { + param([System.Management.Automation.ErrorRecord]$Record) + $exception = $Record.Exception + while ($null -ne $exception) { + # Win32 ERROR_NOT_SAME_DEVICE is 17 (0x11). Move-Item surfaces it as + # the low word of an IOException HRESULT. + if (($exception.HResult -band 0xffff) -eq 17) { return $true } + $exception = $exception.InnerException + } + return $false +} + +function Copy-SkillPathMetadata { + param($SourceItem, [string]$Destination) + (Get-Item -LiteralPath $Destination -Force -ErrorAction Stop).Attributes = $SourceItem.Attributes + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + Set-Acl -LiteralPath $Destination -AclObject (Get-Acl -LiteralPath $SourceItem.FullName -ErrorAction Stop) -ErrorAction Stop + } else { + $mode = [System.IO.File]::GetUnixFileMode($SourceItem.FullName) + [System.IO.File]::SetUnixFileMode($Destination, $mode) + } +} + +function Get-SkillPathPermissionFingerprint { + param([string]$Path) + $nativeWindows = $env:OS -eq "Windows_NT" -or $PSVersionTable.PSEdition -eq "Desktop" + if ($nativeWindows) { + return (Get-Acl -LiteralPath $Path -ErrorAction Stop).Sddl + } + return [string][System.IO.File]::GetUnixFileMode($Path) +} + +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + $item = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + if ($item.LinkType) { + $itemType = if ($item.LinkType -eq "Junction") { "Junction" } else { "SymbolicLink" } + New-Item -ItemType $itemType -Path $Destination -Target $item.Target -ErrorAction Stop | Out-Null + return + } + if ($item.PSIsContainer) { + New-Item -ItemType Directory -Path $Destination -ErrorAction Stop | Out-Null + foreach ($child in @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop)) { + Copy-SkillPathLexically -Source $child.FullName -Destination (Join-Path $Destination $child.Name) + } + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination + return + } + if ($item -isnot [System.IO.FileInfo]) { + throw "不支持复制特殊 Skill 路径 $Source" + } + [System.IO.File]::Copy($Source, $Destination, $false) + Copy-SkillPathMetadata -SourceItem $item -Destination $Destination +} + +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + $sourceItem = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $destinationItem = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + if ([bool]$sourceItem.LinkType -ne [bool]$destinationItem.LinkType -or + $sourceItem.PSIsContainer -ne $destinationItem.PSIsContainer) { + throw "Skill 路径类型不一致: $Source != $Destination" + } + if ($sourceItem.LinkType) { + if ($sourceItem.LinkType -ne $destinationItem.LinkType -or + ($sourceItem.Target -join "`0") -ne ($destinationItem.Target -join "`0")) { + throw "Skill 链接目标不一致: $Source != $Destination" + } + return + } + if ((Get-SkillPathPermissionFingerprint -Path $Source) -ne + (Get-SkillPathPermissionFingerprint -Path $Destination)) { + throw "Skill 路径权限不一致: $Source != $Destination" + } + if ($sourceItem.PSIsContainer) { + $sourceChildren = @(Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop | Sort-Object -Property Name) + $destinationChildren = @(Get-ChildItem -LiteralPath $Destination -Force -ErrorAction Stop | Sort-Object -Property Name) + if ($sourceChildren.Count -ne $destinationChildren.Count) { + throw "Skill 目录项数量不一致: $Source != $Destination" + } + for ($i = 0; $i -lt $sourceChildren.Count; $i++) { + if ($sourceChildren[$i].Name -ne $destinationChildren[$i].Name) { + throw "Skill 目录项不一致: $Source != $Destination" + } + Assert-SkillPathCopy -Source $sourceChildren[$i].FullName -Destination $destinationChildren[$i].FullName + } + return + } + if ($sourceItem.Length -ne $destinationItem.Length) { + throw "Skill 文件大小不一致: $Source != $Destination" + } + $sourceHash = (Get-FileHash -LiteralPath $Source -Algorithm SHA256 -ErrorAction Stop).Hash + $destinationHash = (Get-FileHash -LiteralPath $Destination -Algorithm SHA256 -ErrorAction Stop).Hash + if ($sourceHash -ne $destinationHash) { + throw "Skill 文件内容摘要不一致: $Source != $Destination" + } +} + +function Remove-SkillPathLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop + } else { + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop + } +} + +# Removes a staging directory that may hold unpublished junction/symlink +# children. Every link child must be removed non-recursively first: Windows +# PowerShell 5.1 (the advertised irm|iex surface) can follow reparse points +# during Remove-Item -Recurse and would delete the canonical store's contents +# through the staged link. CI runs pwsh 7, which masks this behavior. +function Remove-LinkStageRoot { + param([string]$StageRoot) + if (!(Test-Path -LiteralPath $StageRoot)) { return $true } + $ok = $true + foreach ($child in @(Get-ChildItem -LiteralPath $StageRoot -Force -ErrorAction SilentlyContinue)) { + try { Remove-SkillPathLexically -Path $child.FullName } catch { $ok = $false } + } + try { + Remove-Item -LiteralPath $StageRoot -Force -ErrorAction Stop + } catch { + $ok = $false + } + return $ok +} + +# Resolves a path to its physical location, dereferencing junction/symlink +# reparse points at any depth. Resolve-Path alone is lexical and never +# recognizes DWS's own junctions, so every rerun would back them up and +# recreate them. Mirrors EvalSymlinks (Go), realpathSync (npm), and cd -P +# (shell) on the other installer surfaces. +function Get-PhysicalSkillPath { + param([string]$Path, [int]$Depth = 0) + if ($Depth -gt 40) { return $null } + $parent = Split-Path $Path -Parent + if ([string]::IsNullOrWhiteSpace($parent)) { return $Path } + $parentPhysical = Get-PhysicalSkillPath -Path $parent -Depth ($Depth + 1) + if ($null -eq $parentPhysical) { return $null } + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrEmpty($leaf)) { return $parentPhysical } + $candidate = Join-Path $parentPhysical $leaf + try { $item = Get-Item -LiteralPath $candidate -Force -ErrorAction Stop } catch { return $null } + $target = @($item.Target) | Where-Object { $_ } | Select-Object -First 1 + if ($item.LinkType -and $target) { + $targetPath = "" + if ([System.IO.Path]::IsPathRooted([string]$target)) { + $targetPath = [string]$target + } else { + $targetPath = Join-Path $parentPhysical ([string]$target) + } + # Resolve the target recursively as well. A custom HOME or canonical + # root can itself sit below another junction, which must compare equal + # to the fully physical path just like EvalSymlinks/realpath do. + return Get-PhysicalSkillPath -Path ([System.IO.Path]::GetFullPath($targetPath)) -Depth ($Depth + 1) + } + return $candidate +} + +# Same-volume moves remain atomic. For a cross-volume backup/restore, stage on +# the destination filesystem, copy links lexically, verify, publish, then +# remove the source. Before source removal every failure keeps the original; +# a removal failure deliberately leaves both verified copies and fails loud. +function Move-SkillPathRecoverably { + param([string]$Source, [string]$Destination) + if (Test-SkillPathLexically -Path $Destination) { throw "移动目标已存在: $Destination" } + $destinationParent = Split-Path $Destination -Parent + New-Item -ItemType Directory -Path $destinationParent -Force -ErrorAction Stop | Out-Null + try { + Move-SkillPath -Source $Source -Destination $Destination + return + } catch { + if (!(Test-CrossDeviceMoveError -Record $_)) { throw } + } + + $stageRoot = Join-Path $destinationParent ("." + (Split-Path $Destination -Leaf) + ".cross-device-" + [guid]::NewGuid().ToString("N")) + $stage = Join-Path $stageRoot "payload" + New-Item -ItemType Directory -Path $stageRoot -ErrorAction Stop | Out-Null + $published = $false + try { + Copy-SkillPathLexically -Source $Source -Destination $stage + Assert-SkillPathCopy -Source $Source -Destination $stage + Move-SkillPath -Source $stage -Destination $Destination + $published = $true + $publication = [pscustomobject]@{ Path = $Destination; Source = $Source } + try { + Assert-SkillPathCopy -Source $Source -Destination $Destination + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + throw "Skill staging 清理失败: $stageRoot" + } + } catch { + $postErr = $_ + try { + Remove-PublishedSkillPathSafely -Record $publication + } catch { + throw "Skill 移动状态不确定:$postErr;撤回目标 $Destination 失败: $_;源 $Source 与目标 $Destination 均保留" + } + throw "Skill 移动失败,目标已撤回,原路径保留 ${Source}: $postErr" + } + try { + Remove-SkillPathLexically -Path $Source + } catch { + throw "Skill 目标已发布但源路径删除失败(源 $Source 与目标 $Destination 均保留): $_" + } + if (Test-SkillPathLexically -Path $Source) { + throw "Skill 目标已发布但源路径仍存在(源 $Source 与目标 $Destination 均保留)" + } + } catch { + $failure = $_ + if (Test-Path -LiteralPath $stageRoot) { + if (!(Remove-LinkStageRoot -StageRoot $stageRoot)) { + if (-not $published) { + throw "$failure;跨设备 Skill staging 清理失败 $stageRoot(备份与原路径均保留)" + } + } + } + throw $failure + } } function Get-Arch { @@ -264,6 +588,26 @@ function Get-GiteeAssetUrl { return "" } +function Assert-ReleaseAssetChecksum { + param([string]$AssetPath, [string]$AssetName, [string]$TempDir) + if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } + if (-not $checksumUrl) { Write-Err "Could not resolve checksums.txt for $Version." } + $checksumPath = Join-Path $TempDir "checksums.txt" + try { + Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing -ErrorAction Stop + } catch { + Write-Err "Could not download checksums.txt for $Version; refusing unverified $AssetName." + } + $expectedLine = Get-Content -LiteralPath $checksumPath | Where-Object { + $_ -match "^[0-9A-Fa-f]{64}[ ]+[*]?$([regex]::Escape($AssetName))$" + } | Select-Object -First 1 + if (-not $expectedLine) { Write-Err "$AssetName is missing from checksums.txt." } + $expected = ($expectedLine -split '\s+')[0].ToLowerInvariant() + $actual = (Get-FileHash -LiteralPath $AssetPath -Algorithm SHA256 -ErrorAction Stop).Hash.ToLowerInvariant() + if ($actual -ne $expected) { Write-Err "SHA256 checksum mismatch for $AssetName. Expected $expected, got $actual." } + Write-Say "✅ SHA256 checksum verified: $AssetName" +} + function Resolve-Source { # Explicit DWS_GITEE_REPO wins; else probe GitHub and fall back to Gitee when unreachable. if ($GiteeRepo -ne "") { return } @@ -389,6 +733,116 @@ function Publish-SkillCache { } } +# Get-SkillBackupName encodes the HOME-relative path of a backed-up Skill +# directory ('.codex\skills\dingtalk-chat' → '.codex-skills-dingtalk-chat') so +# copies retired from different Agent roots stay distinguishable inside one +# stamp. Mirrors build/npm/install.js and internal/upgrade/paths.go. Paths +# outside HOME fall back to the bare leaf. +function Get-SkillBackupName { + param([string]$Dir) + $leaf = Split-Path $Dir -Leaf + try { + $full = [System.IO.Path]::GetFullPath($Dir) + $root = [System.IO.Path]::GetFullPath($HOME).TrimEnd([char[]]@('\', '/')) + } catch { + return $leaf + } + if ([string]::IsNullOrWhiteSpace($root)) { return $leaf } + foreach ($sep in @('\', '/')) { + $prefix = $root + $sep + if ($full.Length -gt $prefix.Length -and + $full.StartsWith($prefix, [System.StringComparison]::OrdinalIgnoreCase)) { + $rel = $full.Substring($prefix.Length).Trim([char[]]@('\', '/')) + if (![string]::IsNullOrWhiteSpace($rel)) { return ($rel -replace '[\\/]+', '-') } + } + } + return $leaf +} + +# SkillBackupKeep bounds $HOME\.dws\skill-backups growth: only the newest +# stamped backup directories are kept, matching skillBackupKeep and +# pruneSkillBackups in internal/upgrade/paths.go. +$SkillBackupKeep = 5 +$script:SkillBackupRootsThisRun = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + +# Ownership marker: every stamp root DWS creates carries .dws-skill-backup +# with exactly "dws skill backup v1" + LF — the same bytes internal/upgrade/ +# paths.go, install.sh, and build/npm/install.js write. A stamp-shaped name +# alone is not ownership proof, so pruning only deletes directories whose +# marker content verifies. +$SkillBackupMarkerFile = ".dws-skill-backup" +$SkillBackupMarkerBody = "dws skill backup v1" + +# Write-SkillBackupMarker stamps a freshly created stamp root as DWS-owned. +# [IO.File]::WriteAllText pins the exact LF-terminated bytes (Set-Content +# would append a platform newline on Windows PowerShell 5.1). +function Write-SkillBackupMarker { + param([string]$Root) + [System.IO.File]::WriteAllText( + (Join-Path $Root $SkillBackupMarkerFile), + "$SkillBackupMarkerBody`n", + [System.Text.UTF8Encoding]::new($false)) +} + +# Test-SkillBackupMarker reports whether a stamp root carries the ownership +# marker. The check normalizes CRLF→LF and drops trailing newlines before +# comparing, so this surface accepts every surface's exact-LF bytes (writer +# and checker agree); any other content, or a missing/unreadable marker, +# means foreign data. +function Test-SkillBackupMarker { + param([string]$Dir) + try { + $marker = Join-Path $Dir $SkillBackupMarkerFile + if (![System.IO.File]::Exists($marker)) { return $false } + $body = [System.IO.File]::ReadAllText($marker).Replace("`r`n", "`n").TrimEnd("`r", "`n") + return ($body -eq $SkillBackupMarkerBody) + } catch { + return $false + } +} + +# Removes a whole stamp root child-first without ever following a reparse +# point: link children are deleted non-recursively, real directories are +# recursed the same way, and only an emptied directory is removed. Backup +# trees can contain junctions/symlinks (victims are collected before the +# physical-equality filter), and Windows PowerShell 5.1 can follow reparse +# points during Remove-Item -Recurse — the invariant Remove-LinkStageRoot +# enforces for staging roots, applied at every depth here. +function Remove-SkillBackupTreeLexically { + param([string]$Path) + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + if ($item.PSIsContainer -and !$item.LinkType) { + foreach ($child in @(Get-ChildItem -LiteralPath $Path -Force -ErrorAction Stop)) { + Remove-SkillBackupTreeLexically -Path $child.FullName + } + } + Remove-Item -LiteralPath $Path -Force -ErrorAction Stop +} + +# Remove-OldSkillBackups deletes the oldest stamped backup directories once +# more than $SkillBackupKeep remain. Stamps sort lexicographically in +# chronological order, so name order is age order. Roots created during this +# run are never pruned: an in-flight transaction still needs them to roll +# back. Best-effort — a prune failure never fails the install. +function Remove-OldSkillBackups { + $root = Join-Path $HOME ".dws\skill-backups" + if (!(Test-Path -LiteralPath $root -PathType Container)) { return } + # Only directories whose names match the DWS backup stamp format (UTC + # yyyyMMdd-HHmmss, optional -N collision suffix) AND whose ownership + # marker verifies are candidates; anything else is foreign data — + # preserved and never counted against $SkillBackupKeep. + $dirs = @(Get-ChildItem -LiteralPath $root -Directory -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -match '^[0-9]{8}-[0-9]{6}(-[0-9]+)?$' -and (Test-SkillBackupMarker -Dir $_.FullName) } | + Sort-Object -Property Name) + $excess = $dirs.Count - $SkillBackupKeep + foreach ($dir in $dirs) { + if ($excess -le 0) { break } + if ($script:SkillBackupRootsThisRun.Contains($dir.FullName)) { continue } + try { Remove-SkillBackupTreeLexically -Path $dir.FullName } catch { } + $excess-- + } +} + # Backup-SkillDir moves $Dir into $HOME\.dws\skill-backups\\ # instead of destroying it (non-interactive installs cannot confirm, so # removals must stay reversible). Missing paths are a no-op success. On any @@ -400,14 +854,21 @@ function Backup-SkillDir { [ref]$BackupPath ) if ($null -ne $BackupPath) { $BackupPath.Value = "" } - if (!(Test-Path $Dir -PathType Container)) { return $true } + if (!(Test-SkillPathLexically -Path $Dir)) { return $true } $backupRoot = Join-Path $HOME ".dws\skill-backups" $stamp = [DateTime]::UtcNow.ToString("yyyyMMdd-HHmmss") - $name = Split-Path $Dir -Leaf - $target = Join-Path (Join-Path $backupRoot $stamp) $name + $name = Get-SkillBackupName -Dir $Dir + $targetRoot = Join-Path $backupRoot $stamp + $target = Join-Path $targetRoot $name $i = 1 - while (Test-Path $target) { - $target = Join-Path (Join-Path $backupRoot "$stamp-$i") $name + # Bump not only when \ is taken but also when the stamp root + # itself exists without a verified ownership marker: a same-second + # foreign directory must never be stamped DWS-owned and pruned later. A + # marker-verified root from this run's same second stays reusable. + while ((Test-SkillPathLexically -Path $target) -or + ((Test-SkillPathLexically -Path $targetRoot) -and !(Test-SkillBackupMarker -Dir $targetRoot))) { + $targetRoot = Join-Path $backupRoot "$stamp-$i" + $target = Join-Path $targetRoot $name $i++ if ($i -gt 1000) { Write-Say "⚠️ 备份目录冲突,保留原目录 $Dir" @@ -416,16 +877,97 @@ function Backup-SkillDir { } try { New-Item -ItemType Directory -Path (Split-Path $target -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $Dir -Destination $target + # Stamp ownership immediately after creating the stamp root and + # before any skill directory moves into it, so an interrupted backup + # can never leave an unmarked (never-prunable) stamp behind. + Write-SkillBackupMarker -Root $targetRoot } catch { - Write-Say "⚠️ 备份失败,保留原目录 $Dir" + # The removal stays non-recursive so a pre-existing non-empty root + # (foreign data) is never destroyed; a failed marker write must not + # leave an empty unowned stamp root behind either. + Remove-Item -LiteralPath $targetRoot -Force -ErrorAction SilentlyContinue + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" + return $false + } + try { + Move-SkillPathRecoverably -Source $Dir -Destination $target + } catch { + Write-Say "⚠️ 备份失败,保留原目录 $Dir`: $_" return $false } if ($null -ne $BackupPath) { $BackupPath.Value = $target } + try { + $script:SkillBackupRootsThisRun.Add([System.IO.Path]::GetFullPath($targetRoot)) | Out-Null + Remove-OldSkillBackups + } catch { + Write-Say "⚠️ 旧备份清理失败(备份本身已成功): $_" + } Write-Say " × 已备份并移除 $Dir → $target" return $true } +function Get-SkillLinkSignature { + param([string]$Path) + try { + $item = Get-Item -LiteralPath $Path -Force -ErrorAction Stop + } catch { + return $null + } + if (!$item.LinkType) { return $null } + $targets = @($item.Target) | ForEach-Object { [string]$_ } + return ([string]$item.LinkType + "`0" + ($targets -join "`0")) +} + +function New-PublishedSkillLinkRecord { + param([string]$Path) + $signature = Get-SkillLinkSignature -Path $Path + if ([string]::IsNullOrEmpty($signature)) { + throw "published Skill path is not a link: $Path" + } + return [pscustomobject]@{ Path = $Path; LinkSignature = $signature } +} + +function New-PublishedSkillCopyRecord { + param([string]$Path, [string]$Source) + Assert-SkillPathCopy -Source $Source -Destination $Path + return [pscustomobject]@{ Path = $Path; Source = $Source } +} + +function Remove-PublishedSkillPathSafely { + param($Record) + $path = [string]$Record.Path + $parent = Split-Path $path -Parent + $quarantine = Join-Path $parent (".dws-link-rollback-" + [guid]::NewGuid().ToString("N")) + + # Claim the current directory entry with an exact same-filesystem rename. + # If another process replaced our link, verification below fails and that + # object is moved back instead of ever being recursively removed. + Move-SkillPath -Source $path -Destination $quarantine + try { + if ($Record.LinkSignature) { + if ((Get-SkillLinkSignature -Path $quarantine) -ne [string]$Record.LinkSignature) { + throw "发布链接已被其他进程替换,拒绝删除" + } + } elseif ($Record.Source) { + Assert-SkillPathCopy -Source ([string]$Record.Source) -Destination $quarantine + } else { + throw "发布路径缺少事务身份,拒绝删除" + } + Remove-SkillPathLexically -Path $quarantine + } catch { + $failure = $_ + if (Test-SkillPathLexically -Path $quarantine) { + try { + if (Test-SkillPathLexically -Path $path) { throw "原路径已被占用" } + Move-SkillPath -Source $quarantine -Destination $path + } catch { + throw "$failure;并发对象保留于 $quarantine`: $_" + } + } + throw $failure + } +} + function Restore-MultiSkillSet { param( [array]$Published, @@ -433,23 +975,29 @@ function Restore-MultiSkillSet { ) $ok = $true for ($i = $Published.Count - 1; $i -ge 0; $i--) { + $publishedItem = $Published[$i] + $publishedPath = if ($publishedItem -is [string]) { $publishedItem } else { [string]$publishedItem.Path } try { - if (Test-Path $Published[$i]) { - Remove-Item -LiteralPath $Published[$i] -Recurse -Force -ErrorAction Stop + if (Test-SkillPathLexically -Path $publishedPath) { + if (!($publishedItem -is [string])) { + Remove-PublishedSkillPathSafely -Record $publishedItem + } else { + Remove-SkillPathLexically -Path $publishedPath + } } } catch { - Write-Say "⚠️ 无法移除失败发布目录 $($Published[$i]): $_" + Write-Say "⚠️ 无法移除失败发布目录 $publishedPath`: $_" $ok = $false } } for ($i = $Backups.Count - 1; $i -ge 0; $i--) { $item = $Backups[$i] try { - if (Test-Path $item.Original) { + if (Test-SkillPathLexically -Path $item.Original) { throw "恢复目标仍存在" } New-Item -ItemType Directory -Path (Split-Path $item.Original -Parent) -Force -ErrorAction Stop | Out-Null - Move-SkillPath -Source $item.Backup -Destination $item.Original + Move-SkillPathRecoverably -Source $item.Backup -Destination $item.Original } catch { Write-Say "⚠️ 无法恢复原 Skill $($item.Original);备份保留于 $($item.Backup): $_" $ok = $false @@ -458,13 +1006,81 @@ function Restore-MultiSkillSet { return $ok } -function Move-GenericSkillRootToBackup { - param([string]$Root) +function Test-SkillPathLexically { + param([string]$Path) + if ([string]::IsNullOrWhiteSpace($Path)) { return $false } + try { + Get-Item -LiteralPath $Path -Force -ErrorAction Stop | Out-Null + return $true + } catch { + $parent = Split-Path $Path -Parent + $leaf = Split-Path $Path -Leaf + if ([string]::IsNullOrWhiteSpace($parent) -or !(Test-Path $parent -PathType Container)) { return $false } + return $null -ne (Get-ChildItem -LiteralPath $parent -Force -ErrorAction SilentlyContinue | + Where-Object { $_.Name -eq $leaf } | Select-Object -First 1) + } +} + +function Resolve-AgentSkillBase { + param([string]$Root, $Agent) + if ($null -eq $Agent.Dir) { return $null } + switch ($Agent.Id) { + "autohand-code" { if ($env:AUTOHAND_HOME) { return (Join-Path $env:AUTOHAND_HOME "skills") } } + "claude-code" { if ($env:CLAUDE_CONFIG_DIR) { return (Join-Path $env:CLAUDE_CONFIG_DIR "skills") } } + "codex" { if ($env:CODEX_HOME) { return (Join-Path $env:CODEX_HOME "skills") } } + "grok" { if ($env:GROK_HOME) { return (Join-Path $env:GROK_HOME "skills") } } + "hermes-agent" { if ($env:HERMES_HOME) { return (Join-Path $env:HERMES_HOME "skills") } } + "mistral-vibe" { if ($env:VIBE_HOME) { return (Join-Path $env:VIBE_HOME "skills") } } + "openclaw" { + foreach ($name in @(".openclaw", ".clawdbot", ".moltbot")) { + $candidate = Join-Path $Root $name + if (Test-Path $candidate -PathType Container) { return (Join-Path $candidate "skills") } + } + } + { $_ -in @("amp", "replit", "universal") } { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + return (Join-Path $configHome "agents\skills") + } + { $_ -in @("crush", "devin", "goose", "opencode") } { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + $child = switch ($Agent.Id) { "crush" { "crush\skills" }; "devin" { "devin\skills" }; "goose" { "goose\skills" }; default { "opencode\skills" } } + return (Join-Path $configHome $child) + } + "kimchi" { + $configHome = if ($env:XDG_CONFIG_HOME) { $env:XDG_CONFIG_HOME } else { Join-Path $Root ".config" } + return (Join-Path $configHome "kimchi\harness\skills") + } + } + return (Join-Path $Root $Agent.Dir) +} + +function Test-AgentSkillBaseDetected { + param([string]$BaseDir, $Agent) + $parent = Split-Path $BaseDir -Parent + switch ($Agent.Id) { + "kimchi" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "tabnine-cli" { return Test-Path (Split-Path $parent -Parent) -PathType Container } + "zcode" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/ZCode.app" -PathType Container) } + "minimax-code" { return (Test-Path $parent -PathType Container) -or (Test-Path "/Applications/MiniMax Code.app" -PathType Container) } + default { return Test-Path $parent -PathType Container } + } +} + +function Test-SamePhysicalSkillRoot { + param([string]$Left, [string]$Right) + if (!(Test-Path $Left) -or !(Test-Path $Right)) { return $false } + $leftPhysical = Get-PhysicalSkillPath -Path $Left + $rightPhysical = Get-PhysicalSkillPath -Path $Right + if ($null -eq $leftPhysical -or $null -eq $rightPhysical) { return $false } + return $leftPhysical.TrimEnd([char[]]@('\', '/')) -ieq $rightPhysical.TrimEnd([char[]]@('\', '/')) +} + +function Move-AgentSkillRootToBackup { + param([string]$Root, [string]$BaseDir) - $baseDir = Join-Path $Root ".agents\skills" $victims = [System.Collections.Generic.List[string]]::new() $victims.Add((Join-Path $baseDir $SkillName)) - foreach ($existing in Get-ChildItem -Path $baseDir -Directory -ErrorAction SilentlyContinue) { + foreach ($existing in Get-ChildItem -Path $baseDir -Force -ErrorAction SilentlyContinue) { if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } @@ -476,7 +1092,7 @@ function Move-GenericSkillRootToBackup { if (!$seen.Add($victim)) { continue } $backupPath = "" if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { - throw "通用 Skill 副本备份失败: $victim" + throw "Agent Skill 旧副本备份失败: $victim" } if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } @@ -485,11 +1101,81 @@ function Move-GenericSkillRootToBackup { return $true } catch { Restore-MultiSkillSet -Published @() -Backups $backups | Out-Null - Write-Say "⚠️ 通用 Skill 副本迁移失败,已回滚: $_" + Write-Say "⚠️ Agent Skill 旧副本迁移失败,已回滚: $_" return $false } } +function Publish-CanonicalSkillLinks { + param([string]$Root, [string]$BaseDir, [string]$Mode, [string[]]$BundleNames = @()) + $canonical = Join-Path $Root ".agents\skills" + if (!(Test-Path $BaseDir)) { New-Item -ItemType Directory -Path $BaseDir -Force | Out-Null } + if (Test-SamePhysicalSkillRoot -Left $BaseDir -Right $canonical) { return $true } + $stageRoot = Join-Path $BaseDir (".dws-link-set-" + [guid]::NewGuid().ToString("N")) + $backups = @() + $published = @() + try { + New-Item -ItemType Directory -Path $stageRoot -Force -ErrorAction Stop | Out-Null + if ($Mode -eq "mono") { + $names = @($SkillName) + } elseif ($BundleNames.Count -gt 0) { + # The canonical store is SHARED: enumerate the link set from the + # installed bundle, never from ~\.agents\skills, or third-party and + # user skills would be republished into every Agent root. + $names = @($BundleNames) + } else { + # No bundle list means we cannot tell DWS skills apart from the + # user's own entries in the shared store. Fail this Agent (the + # caller degrades to a copy install) instead of guessing, matching + # link_canonical_skills_to_base in scripts/install-skills.sh. + throw "multi mode requires the installed bundle skill names" + } + $publishNames = @() + foreach ($name in $names) { + if (Test-SamePhysicalSkillRoot -Left (Join-Path $BaseDir $name) -Right (Join-Path $canonical $name)) { continue } + $absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name)) + New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget -ErrorAction Stop | Out-Null + $publishNames += $name + } + $victims = [System.Collections.Generic.List[string]]::new() + $victims.Add((Join-Path $BaseDir $SkillName)) + foreach ($existing in Get-ChildItem -Path $BaseDir -Force -ErrorAction SilentlyContinue) { + if ($existing.FullName -eq $stageRoot) { continue } + if (Test-ManagedMultiSkillDir -Dir $existing.FullName) { $victims.Add($existing.FullName) } + } + # Every published name replaces whatever occupies its destination, even + # when that copy predates central ownership metadata; the loop below + # still skips destinations that are already correct links. + foreach ($name in $names) { + $victims.Add((Join-Path $BaseDir $name)) + } + $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($victim in $victims) { + if (!$seen.Add($victim)) { continue } + if (Test-SamePhysicalSkillRoot -Left $victim -Right (Join-Path $canonical (Split-Path $victim -Leaf))) { continue } + $backupPath = "" + if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { throw "Skill 备份失败: $victim" } + if ($backupPath) { $backups += [pscustomobject]@{ Original = $victim; Backup = $backupPath } } + } + foreach ($name in $publishNames) { + $dest = Join-Path $BaseDir $name + Move-SkillPath -Source (Join-Path $stageRoot $name) -Destination $dest + # Only record a publication after the staged junction has occupied + # the exact destination. Rollback also checks this identity before + # deleting, so a concurrent user directory is never removed. + $published += New-PublishedSkillLinkRecord -Path $dest + Write-Say "↪ Skills → $dest" + } + return $true + } catch { + Restore-MultiSkillSet -Published $published -Backups $backups | Out-Null + Write-Say "⚠️ Skill 链接发布失败,已回滚: $BaseDir ($_)" + return $false + } finally { + Remove-LinkStageRoot -StageRoot $stageRoot | Out-Null + } +} + function Copy-SkillToDir { param([string]$SkillSrc, [string]$Dest, [string]$Label) @@ -616,26 +1302,7 @@ function Install-Binary { $archivePath = Join-Path $tmpDir $archiveName Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing - # Download and verify SHA256 checksum - if ($GiteeRepo -ne "") { $checksumUrl = Get-GiteeAssetUrl "checksums.txt" } else { $checksumUrl = "https://github.com/$Repo/releases/download/$Version/checksums.txt" } - try { - $checksumPath = Join-Path $tmpDir "checksums.txt" - Invoke-WebRequest -Uri $checksumUrl -OutFile $checksumPath -UseBasicParsing - $checksumContent = Get-Content $checksumPath - $expectedLine = $checksumContent | Where-Object { $_ -match [regex]::Escape($archiveName) } - if ($expectedLine) { - $expected = ($expectedLine -split '\s+')[0] - $actual = (Get-FileHash -Path $archivePath -Algorithm SHA256).Hash.ToLower() - if ($actual -ne $expected.ToLower()) { - Write-Err "SHA256 checksum mismatch! Expected $expected, got $actual. Aborting." - } - Write-Say "✅ SHA256 checksum verified" - } else { - Write-Say "⚠️ Archive not found in checksums.txt; skipping verification" - } - } catch { - Write-Say "⚠️ Could not download checksums.txt; skipping verification" - } + Assert-ReleaseAssetChecksum -AssetPath $archivePath -AssetName $archiveName -TempDir $tmpDir Write-Say "📦 Extracting..." Expand-Archive -Path $archivePath -DestinationPath $tmpDir -Force @@ -784,8 +1451,8 @@ function Install-MonoToBase { } } - $published += $dest Move-SkillPath -Source $stagedSkill -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $SkillSrc } catch { $transactionError = $_ if (!(Restore-MultiSkillSet -Published $published -Backups $backups)) { @@ -811,46 +1478,37 @@ function Install-SkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } - $agentDir = $AgentDirs[$i] - $baseDir = Join-Path $Root $agentDir - $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + $canonical = Join-Path $Root ".agents\skills" + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $canonical -Label "~\.agents\skills\$SkillName") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) { + $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent + if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) + if (!$seenBases.Add($baseKey)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if ($agent.Universal) { + # Cleanup-only migration: universal Agents read the canonical store + # directly, so nothing is installed here. Leaving an obsolete copy + # behind is a warning, never a reason to fail an install whose + # canonical store and links all succeeded. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - $attempted++ - if ($Root -eq $HOME) { - $label = "~\$agentDir\$SkillName" - } else { - $label = Join-Path $Root (Join-Path $agentDir $SkillName) - } - $copied = Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label $label - if ($copied) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "mono") { $installed++ } else { - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - $fallback = Join-Path (Join-Path $Root ".agents\skills") $SkillName - if ($Root -eq $HOME) { - $flabel = "~\.agents\skills\$SkillName" - } else { - $flabel = Join-Path $Root (Join-Path ".agents\skills" $SkillName) - } - if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir (Split-Path $fallback -Parent) -Label $flabel) { - $installed++ - } else { - $failed++ + if (Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir -Label (Join-Path $baseDir $SkillName)) { + Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用" + $installed++ + } else { $failed++ } } } if ($installed -eq 0) { @@ -862,6 +1520,10 @@ function Install-SkillsToHomes { return $false } Remove-Item -LiteralPath (Join-Path $SkillStateRoot "skills-state.json") -Force -ErrorAction SilentlyContinue + Write-Say "✅ DWS Skills 安装完成" + Write-Say " 统一安装位置:$canonical" + Write-Say " 已自动适配本机上检测到的 Agent" + Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" return $true } @@ -889,35 +1551,40 @@ function Install-MultiSkillsToHomes { ) $installed = 0 - $attempted = 0 + $attempted = 1 $failed = 0 - $specificAgents = @($AgentDirs | Select-Object -Skip 1 | Where-Object { - Test-Path (Split-Path (Join-Path $Root $_) -Parent) - }) - for ($i = 0; $i -lt $AgentDirs.Count; $i++) { - if ($i -eq 0 -and $specificAgents.Count -gt 0) { continue } - $agentDir = $AgentDirs[$i] - $baseDir = Join-Path $Root $agentDir - $parentGate = Split-Path $baseDir -Parent - if ($i -gt 0 -and !(Test-Path $parentGate)) { + $canonical = Join-Path $Root ".agents\skills" + # The link set must come from the installed bundle, not from the shared + # canonical store (which may also hold user/third-party skills). + $bundleNames = @(Get-ChildItem -Path $MultiSrc -Directory -ErrorAction SilentlyContinue | Where-Object { + Test-Path (Join-Path $_.FullName "SKILL.md") + } | ForEach-Object { $_.Name }) + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $canonical -Root $Root -AgentDir ".agents\skills") { $installed++ } else { $failed++ } + if ($installed -eq 0) { return $false } + $seenBases = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + foreach ($agent in @($AgentRegistry) + @($LegacyAgentCleanupTargets)) { + $baseDir = Resolve-AgentSkillBase -Root $Root -Agent $agent + if ([string]::IsNullOrWhiteSpace($baseDir)) { continue } + $baseKey = [System.IO.Path]::GetFullPath($baseDir).TrimEnd([char[]]@('\', '/')) + if (!$seenBases.Add($baseKey)) { continue } + if (!(Test-AgentSkillBaseDetected -BaseDir $baseDir -Agent $agent)) { continue } + if (Test-SamePhysicalSkillRoot -Left $baseDir -Right $canonical) { continue } + $attempted++ + if ($agent.Universal) { + # Cleanup-only migration (see Install-SkillsToHomes): a retire + # failure must not fail an otherwise complete install. + if (!(Move-AgentSkillRootToBackup -Root $Root -BaseDir $baseDir)) { + Write-Say "⚠️ Agent Skill 旧副本迁移失败(不影响本次安装): $baseDir" + } continue } - $attempted++ - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agentDir) { + if (Publish-CanonicalSkillLinks -Root $Root -BaseDir $baseDir -Mode "multi" -BundleNames $bundleNames) { $installed++ } else { - Write-Say "⚠️ 跳过 $baseDir(备份失败,未安装 multi)" - $failed++ - } - } - if ($specificAgents.Count -gt 0 -and $installed -gt 0) { - if (!(Move-GenericSkillRootToBackup -Root $Root)) { $failed++ } - } - if ($attempted -eq 0) { - if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir (Join-Path $Root ".agents\skills") -Root $Root -AgentDir ".agents\skills") { - $installed++ - } else { - $failed++ + if (Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir -Root $Root -AgentDir $agent.Dir) { + Write-Say "ℹ️ $baseDir 已自动使用兼容方式安装,可正常使用" + $installed++ + } else { $failed++ } } } if ($installed -eq 0) { @@ -929,6 +1596,10 @@ function Install-MultiSkillsToHomes { return $false } Write-SkillsState -MultiSrc $MultiSrc + Write-Say "✅ DWS Skills 安装完成" + Write-Say " 统一安装位置:$canonical" + Write-Say " 已自动适配本机上检测到的 Agent" + Write-Say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" return $true } @@ -990,8 +1661,8 @@ function Install-MultiToBase { foreach ($skillDir in $skillDirs) { $dest = Join-Path $BaseDir $skillDir.Name - $published += $dest Move-SkillPath -Source (Join-Path $stageRoot $skillDir.Name) -Destination $dest + $published += New-PublishedSkillCopyRecord -Path $dest -Source $skillDir.FullName } } catch { $transactionError = $_ @@ -1072,6 +1743,7 @@ function Install-Skills { Write-Err "Cannot download skills from GitHub and no local source checkout found." } } + Assert-ReleaseAssetChecksum -AssetPath $zipPath -AssetName "dws-skills.zip" -TempDir $tmpDir $extractRoot = Join-Path $tmpDir "skills" Expand-Archive -Path $zipPath -DestinationPath $extractRoot -Force diff --git a/scripts/install.sh b/scripts/install.sh index 016055fe..9505ff13 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -71,24 +71,54 @@ DWS_LAST_SKILL_BACKUP="" backup_and_remove_skill_dir() { _bed_dir="$1" DWS_LAST_SKILL_BACKUP="" - [ -d "$_bed_dir" ] || return 0 + [ -e "$_bed_dir" ] || [ -L "$_bed_dir" ] || return 0 _bed_root="${HOME}/.dws/skill-backups" _bed_stamp="$(date -u +%Y%m%d-%H%M%S)" _bed_name="$(basename "$_bed_dir")" - _bed_target="$_bed_root/$_bed_stamp/$_bed_name" + _bed_stamp_root="$_bed_root/$_bed_stamp" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=1 - while [ -e "$_bed_target" ]; do - _bed_target="$_bed_root/$_bed_stamp-$_bed_i/$_bed_name" + # Bump not only when the payload path is taken but also when the stamp + # root exists without a verified ownership marker: a same-second foreign + # directory must never be stamped DWS-owned and made prunable. A + # marker-verified root from this run's same second stays reusable. + while [ -e "$_bed_target" ] || [ -L "$_bed_target" ] || + { [ -d "$_bed_stamp_root" ] && ! is_current_run_backup_stamp "$_bed_stamp_root" && + [ "$(cat "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null)" != "dws skill backup v1" ]; }; do + _bed_stamp_root="$_bed_root/$_bed_stamp-$_bed_i" + _bed_target="$_bed_stamp_root/$_bed_name" _bed_i=$((_bed_i + 1)) if [ "$_bed_i" -gt 1000 ]; then say " ⚠️ 备份目录冲突,保留原目录 $_bed_dir" return 1 fi done - mkdir -p "$(dirname "$_bed_target")" 2>/dev/null || { + _bed_stamp_root="$(dirname "$_bed_target")" + record_current_run_backup_stamp "$_bed_stamp_root" + # Freshness must be sampled before mkdir: the collision loop tests the + # payload path, so a second backup in the same stamp second reuses this + # root while a sibling payload from this run already lives in it. + _bed_fresh=1 + [ ! -d "$_bed_stamp_root" ] || _bed_fresh=0 + mkdir -p "$_bed_stamp_root" 2>/dev/null || { say " ⚠️ 无法创建备份目录,保留原目录 $_bed_dir" return 1 } + # Ownership proof, the exact bytes Go's writeSkillBackupMarker stamps: a + # stamp-shaped name alone is not evidence, so pruning only ever removes + # roots carrying this marker — it must exist before any payload moves in. + printf '%s\n' 'dws skill backup v1' > "$_bed_stamp_root/.dws-skill-backup" 2>/dev/null || { + # Non-recursive cleanup, sibling protection: only a root this call + # created may be dropped, and only the marker plus an empty root (rmdir + # refuses a non-empty directory) — rm -rf here would destroy a completed + # same-second sibling backup whose original is already moved away. + if [ "$_bed_fresh" -eq 1 ]; then + rm -f "$_bed_stamp_root/.dws-skill-backup" + rmdir "$_bed_stamp_root" 2>/dev/null || true + fi + say " ⚠️ 无法写入备份所有权标记,保留原目录 $_bed_dir" + return 1 + } if mv "$_bed_dir" "$_bed_target" 2>/dev/null; then DWS_LAST_SKILL_BACKUP="$_bed_target" say " × 已备份并移除 $_bed_dir → $_bed_target" @@ -98,6 +128,88 @@ backup_and_remove_skill_dir() { return 1 } +# prune_skill_backups keeps only the newest DWS_SKILL_BACKUP_KEEP stamped +# backup directories from earlier runs under $HOME/.dws/skill-backups, +# matching Go's skillBackupKeep / pruneSkillBackups. Only stamp-shaped roots +# carrying the .dws-skill-backup marker with the exact expected content are +# counted or removed: foreign data with a stamp-like name never consumes a +# keep slot and is never deleted, silently. Stamp directories created +# by the current process are never pruned (mirroring Go's run-root registry +# and install.js's currentRunBackupRoots), so a migration that retires more +# than DWS_SKILL_BACKUP_KEEP batches stays reversible. Best-effort: a removal +# failure is reported, never fatal. +DWS_SKILL_BACKUP_KEEP=5 + +# Stamp directories created by this run, recorded by +# backup_and_remove_skill_dir so pruning can never delete a backup the +# running migration may still need to roll back to. +DWS_CURRENT_RUN_BACKUP_STAMPS="" + +record_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + DWS_CURRENT_RUN_BACKUP_STAMPS="${DWS_CURRENT_RUN_BACKUP_STAMPS} $1" +} + +# is_current_run_backup_stamp reports whether the stamp root was created by +# this very process. Such a root is ours by construction and stays reusable +# even when its marker cannot be re-verified mid-run (for example a +# permission failure after the first payload moved in). +is_current_run_backup_stamp() { + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $1 "*) return 0 ;; + esac + return 1 +} + +# is_skill_backup_stamp accepts only directory names with the stamp shape DWS +# itself writes: UTC YYYYmmdd-HHMMSS, with an optional -N collision suffix. +# Shape is necessary but not sufficient — pruning additionally verifies the +# .dws-skill-backup ownership marker — while any other entry in the backup +# root is foreign (user data, unrelated tooling) and is preserved so pruning +# can never remove a directory it cannot prove DWS created. +is_skill_backup_stamp() { + case "$1" in + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]) + return 0 ;; + [0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9][0-9][0-9]-*) + # base stamp is YYYYMMDD-HHMMSS (15 chars) + suffix dash (16th); strip + # those 16 chars and require the remainder to be all digits. + _isbs_suffix="${1#????????????????}" + case "$_isbs_suffix" in + ""|*[!0-9]*) return 1 ;; + esac + return 0 ;; + esac + return 1 +} + +prune_skill_backups() { + _psb_root="${HOME}/.dws/skill-backups" + [ -d "$_psb_root" ] || return 0 + _psb_total=0 + for _psb_entry in "$_psb_root"/*; do + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + _psb_total=$((_psb_total + 1)) + done + [ "$_psb_total" -gt "$DWS_SKILL_BACKUP_KEEP" ] || return 0 + _psb_drop=$((_psb_total - DWS_SKILL_BACKUP_KEEP)) + for _psb_entry in "$_psb_root"/*; do + [ "$_psb_drop" -gt 0 ] || break + [ -d "$_psb_entry" ] || continue + is_skill_backup_stamp "${_psb_entry##*/}" || continue + [ "$(cat "$_psb_entry/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || continue + case " $DWS_CURRENT_RUN_BACKUP_STAMPS " in + *" $_psb_entry "*) continue ;; + esac + rm -rf "$_psb_entry" || say " ⚠️ 旧 Skill 备份清理失败: $_psb_entry" + _psb_drop=$((_psb_drop - 1)) + done +} + # A dingtalk-* prefix alone is not ownership evidence: market/user skills may # use it too. Ownership comes from the centralized skills-state.json. is_managed_multi_skill_dir() { @@ -141,6 +253,40 @@ sha256_stdin() { fi } +# sha256_file : content digest used by skill_dir_content_digest. Never +# emits on failure. Callers that capture its status directly detect that +# failure; skill_dir_content_digest cannot — see the pipeline caveat there: +# POSIX sh returns only the right-hand pipeline status, so a subshell exit on +# its left side is discarded and digest-failure detection stays best-effort. +sha256_file() { + if need_cmd sha256sum; then + sha256sum "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd shasum; then + shasum -a 256 "$1" 2>/dev/null | awk '{print $1}' + elif need_cmd openssl; then + openssl dgst -sha256 -r "$1" 2>/dev/null | awk '{print $1}' + else + return 1 + fi +} + +verify_release_asset_checksum() { + _checksum_asset="$1" + _checksum_path="$2" + _checksum_dir="$3" + _checksum_url="$(asset_url checksums.txt)" + [ -n "$_checksum_url" ] || err "Could not resolve checksums.txt for ${VERSION}." + download "$_checksum_url" "$_checksum_dir/checksums.txt" 2>/dev/null || \ + err "Could not download checksums.txt for ${VERSION}; refusing unverified ${_checksum_asset}." + _checksum_expected="$(awk -v file="$_checksum_asset" '$2 == file {print $1; exit}' "$_checksum_dir/checksums.txt")" + [ -n "$_checksum_expected" ] || err "${_checksum_asset} is missing from checksums.txt." + _checksum_actual="$(sha256_stdin < "$_checksum_path")" || \ + err "Could not compute SHA256 for ${_checksum_asset}; install sha256sum, shasum, or openssl." + [ "$_checksum_actual" = "$_checksum_expected" ] || \ + err "SHA256 checksum mismatch for ${_checksum_asset}. Expected ${_checksum_expected}, got ${_checksum_actual}." + say "✅ SHA256 checksum verified: ${_checksum_asset}" +} + digest_skill_dir() { _digest_dir="$1" _digest="$({ @@ -208,31 +354,155 @@ backup_and_record_skill_dir() { backup_and_remove_skill_dir "$_bars_victim" || return 1 if [ -n "$DWS_LAST_SKILL_BACKUP" ]; then if ! printf '%s\n%s\n' "$_bars_victim" "$DWS_LAST_SKILL_BACKUP" >> "$_bars_manifest"; then - mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: $_bars_victim(备份位于 $DWS_LAST_SKILL_BACKUP)" + mv "$DWS_LAST_SKILL_BACKUP" "$_bars_victim" 2>/dev/null || say " ⚠️ 备份记录失败且无法自动恢复: ${_bars_victim}(备份位于 ${DWS_LAST_SKILL_BACKUP})" return 1 fi fi } +# restore_quarantine_no_replace +# Puts an unmatched quarantined object back onto dest without replacing +# anything that occupied dest after the claim. Directories use mkdir-claim +# plus child moves; links are created at dest; files hard-link then drop +# the quarantine copy. +restore_quarantine_no_replace() { + _rqn_src="$1" + _rqn_dest="$2" + if [ -L "$_rqn_src" ]; then + _rqn_target="$(readlink "$_rqn_src")" || return 1 + if ! ln -s "$_rqn_target" "$_rqn_dest" 2>/dev/null || ! [ -L "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/${_rqn_target##*/}" + if [ -L "$_rqn_nested" ] && [ "$(readlink "$_rqn_nested" 2>/dev/null)" = "$_rqn_target" ]; then + rm -f "$_rqn_nested" + fi + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + if [ -d "$_rqn_src" ]; then + mkdir "$_rqn_dest" 2>/dev/null || return 1 + _rqn_failed=0 + for _rqn_child in "$_rqn_src"/* "$_rqn_src"/.[!.]* "$_rqn_src"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + if ! mv "$_rqn_child" "$_rqn_dest/"; then + _rqn_failed=1 + break + fi + done + if [ "$_rqn_failed" -eq 0 ]; then + rmdir "$_rqn_src" 2>/dev/null || return 1 + return 0 + fi + for _rqn_child in "$_rqn_dest"/* "$_rqn_dest"/.[!.]* "$_rqn_dest"/..?*; do + [ -e "$_rqn_child" ] || [ -L "$_rqn_child" ] || continue + mv "$_rqn_child" "$_rqn_src/" || return 1 + done + rmdir "$_rqn_dest" 2>/dev/null || return 1 + return 1 + fi + if [ -f "$_rqn_src" ]; then + # Shell `ln` treats an occupied directory as a container. Refuse that + # dest and drop any nested name instead of leaving a hardlink inside it. + if ! ln "$_rqn_src" "$_rqn_dest" 2>/dev/null || [ -d "$_rqn_dest" ]; then + _rqn_nested="$_rqn_dest/$(basename "$_rqn_src")" + [ -f "$_rqn_nested" ] && rm -f "$_rqn_nested" + return 1 + fi + rm -f "$_rqn_src" + return 0 + fi + return 1 +} + +# quarantine_retract_or_restore [expected-link-target] +# Prove ownership at dest first (inode plus child names for directories). +# Quarantining before that check would move a concurrent replacement off its +# original path. Linux overlayfs recycles inodes, so inode alone is not +# enough — a dest whose children no longer match the recorded publication +# stays put. After a matching dest is claimed, identity is re-checked in +# quarantine; a mismatch is restored with no-replace. +quarantine_retract_or_restore() { + _qrr_dest="$1" + _qrr_inode="$2" + _qrr_link_target="${3-}" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_dest" ] && + [ "$(readlink "$_qrr_dest" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_dest")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 0 ]; then + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi + _qrr_parent="$(dirname "$_qrr_dest")" + _qrr_base="$(basename "$_qrr_dest")" + _qrr_root="$(mktemp -d "$_qrr_parent/.${_qrr_base}.rollback.XXXXXX")" || return 1 + _qrr_payload="$_qrr_root/payload" + if ! mv "$_qrr_dest" "$_qrr_payload"; then + rmdir "$_qrr_root" 2>/dev/null || rm -rf "$_qrr_root" + if [ ! -e "$_qrr_dest" ] && [ ! -L "$_qrr_dest" ]; then + return 0 + fi + say " ⚠️ 无法隔离待回滚 Skill: $_qrr_dest" + return 1 + fi + _qrr_owned=0 + if [ -n "$_qrr_link_target" ]; then + if [ -L "$_qrr_payload" ] && + [ "$(readlink "$_qrr_payload" 2>/dev/null)" = "$_qrr_link_target" ] && + [ "$(skill_link_inode "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + elif [ "$(skill_dir_identity "$_qrr_payload")" = "$_qrr_inode" ]; then + _qrr_owned=1 + fi + if [ "$_qrr_owned" -eq 1 ]; then + rm -rf "$_qrr_root" || return 1 + return 0 + fi + if restore_quarantine_no_replace "$_qrr_payload" "$_qrr_dest"; then + rmdir "$_qrr_root" 2>/dev/null || true + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest" + return 1 + fi + say " ⚠️ 跳过回滚已被并发修改的 Skill 路径: $_qrr_dest(并发对象保留于 $_qrr_payload)" + return 1 +} + # restore_multi_skill_set # Removes partial new publications, then restores every old directory from -# its exact backup path. Paths containing newlines are outside the supported -# installer path contract; spaces are preserved. +# its exact backup path. Publication manifest entries are : +# pairs recorded at publish time. Dest is claimed into quarantine first; +# identity is re-checked there so a concurrent replacement is renamed back +# instead of being deleted by path. Paths containing newlines are outside +# the supported installer path contract; spaces are preserved. restore_multi_skill_set() { _rms_published="$1" _rms_backups="$2" _rms_ok=1 if [ -f "$_rms_published" ]; then - while IFS= read -r _rms_dest; do - [ -n "$_rms_dest" ] || continue - rm -rf "$_rms_dest" || _rms_ok=0 + while IFS= read -r _rms_entry; do + [ -n "$_rms_entry" ] || continue + _rms_dest="${_rms_entry%:*}" + _rms_inode="${_rms_entry##*:}" + [ "$_rms_dest" != "$_rms_entry" ] || { say " ⚠️ 跳过格式异常的发布记录: $_rms_entry"; _rms_ok=0; continue; } + quarantine_retract_or_restore "$_rms_dest" "$_rms_inode" || _rms_ok=0 done < "$_rms_published" fi if [ -f "$_rms_backups" ]; then while IFS= read -r _rms_original && IFS= read -r _rms_backup; do [ -n "$_rms_backup" ] || continue - if [ -e "$_rms_original" ] || ! mkdir -p "$(dirname "$_rms_original")" || ! mv "$_rms_backup" "$_rms_original"; then - say " ⚠️ 无法恢复原 Skill: $_rms_original(备份保留于 $_rms_backup)" + if ! mkdir -p "$(dirname "$_rms_original")" || ! restore_quarantine_no_replace "$_rms_backup" "$_rms_original"; then + say " ⚠️ 无法恢复原 Skill: ${_rms_original}(备份保留于 ${_rms_backup})" _rms_ok=0 fi done < "$_rms_backups" @@ -240,6 +510,137 @@ restore_multi_skill_set() { [ "$_rms_ok" -eq 1 ] } +# A link publication manifest stores destination/target/inode triples. Rollback +# only removes links that still have the identity created by this transaction; +# a path concurrently replaced with a file, directory, or new link is preserved. +skill_link_inode() { + _sli_entry="$(ls -di "$1" 2>/dev/null)" || return 1 + printf '%s\n' "$_sli_entry" | awk '{print $1}' +} + +# Directory publication identity is inode, a stable child-name list, and a +# recursive content digest. Inode reuse on Linux overlayfs would otherwise +# make a concurrent directory look owned, and an in-place content edit after +# publish (same inode, same names) would otherwise be retracted as this +# transaction's object. The token has no colon so dest:identity manifest +# parsing stays dest="${entry%:*}" / identity="${entry##*:}". +skill_dir_identity() { + _sdi_inode="$(skill_link_inode "$1")" || return 1 + if [ -L "$1" ] || [ ! -d "$1" ]; then + printf '%s\n' "$_sdi_inode" + return 0 + fi + _sdi_names="$(LC_ALL=C ls -A "$1" 2>/dev/null | tr '\n' ',')" + _sdi_digest="$(skill_dir_content_digest "$1")" || return 1 + printf '%s,%s,%s\n' "$_sdi_inode" "$_sdi_names" "$_sdi_digest" +} + +# Recursive content digest over a published tree: for each path (LC_ALL=C +# sorted, relative) emit path, type+permission bits from ls -ld, and either +# the sha256 of the file content or the symlink target. Sorted-path lines are +# piped through sha256_stdin, so one opaque token covers renames, mode flips, +# content edits and link retargets — the same in-place-edit guarantee the Go +# fingerprint provides. Failure detection is asymmetric and best-effort: with +# no hash tool installed sha256_stdin itself returns 1 and the identity +# genuinely fails, but an unreadable file only exits the left-hand subshell — +# /bin/sh has no pipefail, so the pipeline returns sha256_stdin's status and +# the digest is computed over a truncated stream instead of failing. +skill_dir_content_digest() { + ( + cd "$1" 2>/dev/null || exit 1 + find . -print 2>/dev/null | + LC_ALL=C sort | + while IFS= read -r _sdcd_path; do + _sdcd_mode="$(ls -ld "$_sdcd_path" 2>/dev/null | cut -c1-10)" + if [ -L "$_sdcd_path" ]; then + printf '%s|%s|link|%s\n' "$_sdcd_path" "$_sdcd_mode" "$(readlink "$_sdcd_path" 2>/dev/null)" + elif [ -d "$_sdcd_path" ]; then + printf '%s|%s|dir\n' "$_sdcd_path" "$_sdcd_mode" + elif [ -f "$_sdcd_path" ]; then + _sdcd_hash="$(sha256_file "$_sdcd_path")" || exit 1 + printf '%s|%s|file|%s\n' "$_sdcd_path" "$_sdcd_mode" "$_sdcd_hash" + else + printf '%s|%s|other\n' "$_sdcd_path" "$_sdcd_mode" + fi + done + ) | sha256_stdin +} + +skill_link_matches() { + [ -L "$1" ] || return 1 + _slm_target="$(readlink "$1" 2>/dev/null)" || return 1 + [ "$_slm_target" = "$2" ] || return 1 + _slm_expected_inode="${3-}" + [ -z "$_slm_expected_inode" ] || [ "$(skill_link_inode "$1")" = "$_slm_expected_inode" ] +} + +restore_linked_skill_set() { + _rls_published="$1" + _rls_backups="$2" + _rls_ok=1 + if [ -f "$_rls_published" ]; then + while IFS= read -r _rls_dest && IFS= read -r _rls_target && IFS= read -r _rls_inode; do + [ -n "$_rls_dest" ] || continue + quarantine_retract_or_restore "$_rls_dest" "$_rls_inode" "$_rls_target" || _rls_ok=0 + done < "$_rls_published" + fi + restore_multi_skill_set /dev/null "$_rls_backups" || _rls_ok=0 + [ "$_rls_ok" -eq 1 ] +} + +cleanup_nested_staged_link() { + _cnsl_nested="$1/$2" + _cnsl_target="$3" + _cnsl_inode="${4-}" + if skill_link_matches "$_cnsl_nested" "$_cnsl_target" "$_cnsl_inode"; then + rm -f "$_cnsl_nested" + fi +} + +# publish_skill_dir_no_replace +# Publishes a staged Skill directory with an atomic no-replace claim: mkdir +# fails with EEXIST when anything a concurrent writer created occupies the +# destination, so the claim itself is the existence check — plain `mv` after a +# backup could still replace a concurrently created object. Staged children +# then move into the claim one by one (each rename targets a path inside a +# directory this transaction owns, so no step replaces a foreign object; the +# staging directory is created under the same umask as the claim, so their +# modes match by construction). A failed child move relocates the children +# back and removes only the claim. On success the destination's inode is +# recorded as : so rollback only ever +# deletes this transaction's object even when the filesystem recycles the +# claim inode or the published files are edited in place afterwards. +publish_skill_dir_no_replace() { + _psd_stage="$1" + _psd_dest="$2" + _psd_manifest="$3" + + if ! mkdir "$_psd_dest" 2>/dev/null; then + return 1 + fi + _psd_failed=0 + for _psd_child in "$_psd_stage"/* "$_psd_stage"/.[!.]* "$_psd_stage"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + if ! mv "$_psd_child" "$_psd_dest/"; then + _psd_failed=1 + break + fi + done + if [ "$_psd_failed" -eq 0 ]; then + _psd_inode="$(skill_dir_identity "$_psd_dest")" || _psd_failed=1 + if [ "$_psd_failed" -eq 0 ] && printf '%s:%s\n' "$_psd_dest" "$_psd_inode" >> "$_psd_manifest"; then + return 0 + fi + _psd_failed=1 + fi + for _psd_child in "$_psd_dest"/* "$_psd_dest"/.[!.]* "$_psd_dest"/..?*; do + [ -e "$_psd_child" ] || [ -L "$_psd_child" ] || continue + mv "$_psd_child" "$_psd_stage/" || return 1 + done + rmdir "$_psd_dest" 2>/dev/null || return 1 + return 1 +} + # publish_skill_cache # Copies a complete cache into a sibling staging directory, then publishes it # with rename. Copy/publish failures retain the previous cache; if restoration @@ -627,12 +1028,7 @@ _install_mono_to_base() { done _mono_dest="$_mono_base/$SKILL_NAME" - printf '%s\n' "$_mono_dest" >> "$_mono_published" || { - restore_multi_skill_set "$_mono_published" "$_mono_backups" || true - rm -rf "$_mono_stage" - return 1 - } - if ! mv "$_mono_stage/$SKILL_NAME" "$_mono_dest"; then + if ! publish_skill_dir_no_replace "$_mono_stage/$SKILL_NAME" "$_mono_dest" "$_mono_published"; then say " ⚠️ mono Skill 集合发布失败,正在恢复原集合: $_mono_dest" restore_multi_skill_set "$_mono_published" "$_mono_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_mono_stage" @@ -643,17 +1039,100 @@ _install_mono_to_base() { say "✅ Skills → ${_mono_label} (${_mono_count} files)" } -# Move DWS-owned copies out of the generic root once a concrete Agent root is -# active. This prevents Agents such as Codex from discovering the same Skill -# through both ~/.agents/skills and ~/.codex/skills. -retire_generic_skill_root() { +# Exact upstream registry (76 IDs): id|classification|effective-global-root. +# `-` means no global directory; `.agents/skills` means canonical-direct. +# Not readonly: the library must stay re-sourceable inside one shell. +DWS_UPSTREAM_AGENT_REGISTRY='aider-desk|N|.aider-desk/skills amp|U|.config/agents/skills antigravity|U|.gemini/antigravity/skills antigravity-cli|U|.gemini/antigravity-cli/skills astrbot|N|.astrbot/data/skills autohand-code|N|.autohand/skills augment|N|.augment/skills bob|N|.bob/skills claude-code|N|.claude/skills openclaw|N|.openclaw/skills cline|U|.agents/skills codearts-agent|N|.codeartsdoer/skills codebuddy|N|.codebuddy/skills codemaker|N|.codemaker/skills codestudio|N|.codestudio/skills codex|U|.codex/skills command-code|N|.commandcode/skills continue|N|.continue/skills cortex|N|.snowflake/cortex/skills crush|N|.config/crush/skills cursor|U|.cursor/skills deepagents|U|.deepagents/agent/skills devin|N|.config/devin/skills dexto|U|.agents/skills droid|N|.factory/skills eve|N|- firebender|U|.firebender/skills forgecode|N|.forge/skills gemini-cli|U|.gemini/skills github-copilot|U|.copilot/skills goose|N|.config/goose/skills grok|N|.grok/skills hermes-agent|N|.hermes/skills inference-sh|N|.inferencesh/skills jazz|N|.jazz/skills junie|N|.junie/skills iflow-cli|N|.iflow/skills kilo|N|.kilocode/skills kimchi|N|.config/kimchi/harness/skills kimi-code-cli|U|.agents/skills kiro-cli|N|.kiro/skills kode|N|.kode/skills lingma|N|.lingma/skills loaf|U|.agents/skills mcpjam|N|.mcpjam/skills minimax-code|N|.minimax/skills mistral-vibe|N|.vibe/skills moxby|N|.moxby/skills mux|N|.mux/skills opencode|U|.config/opencode/skills openhands|N|.openhands/skills ona|N|.ona/skills pi|N|.pi/agent/skills qoder|N|.qoder/skills qoder-cn|N|.qoder-cn/skills qwen-code|N|.qwen/skills replit|U|.config/agents/skills reasonix|N|.reasonix/skills rovodev|N|.rovodev/skills roo|N|.roo/skills tabnine-cli|N|.tabnine/agent/skills terramind|N|.terramind/skills tinycloud|N|.tinycloud/skills trae|N|.trae/skills trae-cn|N|.trae-cn/skills warp|U|.agents/skills windsurf|N|.codeium/windsurf/skills zed|U|.agents/skills zcode|N|.zcode/skills zencoder|N|.zencoder/skills zenflow|N|.zencoder/skills neovate|N|.neovate/skills pochi|N|.pochi/skills promptscript|U|- adal|N|.adal/skills universal|U|.config/agents/skills' +upstream_agent_registry() { + for _uar_record in $DWS_UPSTREAM_AGENT_REGISTRY; do printf '%s\n' "$_uar_record"; done +} + +# DWS-only compatibility roots, same id|classification|root format. Qoderwork +# stays a real non-universal install target; the other four are global paths +# older DWS installers wrote by mistake, so they are retired like universal +# roots. Kept separate so the upstream pin above stays byte-comparable. +DWS_LEGACY_AGENT_ROOTS='dws-qoderwork|N|.qoderwork/skills dws-legacy-github|U|.github/skills dws-legacy-windsurf|U|.windsurf/skills dws-legacy-cline|U|.cline/skills dws-legacy-amp|U|.amp/skills' +legacy_agent_roots() { + for _lar_record in $DWS_LEGACY_AGENT_ROOTS; do printf '%s\n' "$_lar_record"; done +} + +# ~/.agents/skills is the canonical store under the universal convention. +# Universal Agents read it directly; other Agents receive relative links and +# fall back to copies only when links are unavailable. +# +# Both agent_skill_dirs and is_universal_agent_dir are DERIVED from the pinned +# registries above, so a registry edit changes real install behaviour instead of +# only a test count, and pin/behaviour drift is impossible by construction. +is_universal_agent_dir() { + for _iua_record in $DWS_UPSTREAM_AGENT_REGISTRY $DWS_LEGACY_AGENT_ROOTS; do + [ "${_iua_record##*|}" = "$1" ] || continue + case "$_iua_record" in + *"|U|"*) return 0 ;; + esac + done + return 1 +} + +# Effective global roots from vercel-labs/skills, in pinned registry order and +# de-duplicated (amp/replit/universal and zencoder/zenflow share a root). +# Records without a global root (`-`) and canonical-direct records +# (`.agents/skills`) are not per-Agent targets and are skipped. +agent_skill_dirs() { + { upstream_agent_registry; legacy_agent_roots; } | awk -F'|' ' + $3 != "-" && $3 != ".agents/skills" && !seen[$3]++ { print $3 }' +} + +resolve_agent_skill_base() { + _ras_root="$1"; _ras_agent="$2" + case "$_ras_agent" in + ".claude/skills") [ -n "${CLAUDE_CONFIG_DIR:-}" ] && { printf '%s\n' "$CLAUDE_CONFIG_DIR/skills"; return; } ;; + ".codex/skills") [ -n "${CODEX_HOME:-}" ] && { printf '%s\n' "$CODEX_HOME/skills"; return; } ;; + ".hermes/skills") [ -n "${HERMES_HOME:-}" ] && { printf '%s\n' "$HERMES_HOME/skills"; return; } ;; + ".autohand/skills") [ -n "${AUTOHAND_HOME:-}" ] && { printf '%s\n' "$AUTOHAND_HOME/skills"; return; } ;; + ".grok/skills") [ -n "${GROK_HOME:-}" ] && { printf '%s\n' "$GROK_HOME/skills"; return; } ;; + ".vibe/skills") [ -n "${VIBE_HOME:-}" ] && { printf '%s\n' "$VIBE_HOME/skills"; return; } ;; + ".openclaw/skills") + for _ras_name in .openclaw .clawdbot .moltbot; do + [ -d "$_ras_root/$_ras_name" ] && { printf '%s\n' "$_ras_root/$_ras_name/skills"; return; } + done ;; + ".config/opencode/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/opencode/skills"; return ;; + ".config/agents/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/agents/skills"; return ;; + ".config/crush/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/crush/skills"; return ;; + ".config/devin/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/devin/skills"; return ;; + ".config/goose/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/goose/skills"; return ;; + ".config/kimchi/harness/skills") printf '%s\n' "${XDG_CONFIG_HOME:-$_ras_root/.config}/kimchi/harness/skills"; return ;; + esac + printf '%s\n' "$_ras_root/$_ras_agent" +} + +agent_skill_base_detected() { + _asbd_agent="$1"; _asbd_base="$2" + case "$_asbd_agent" in + ".config/kimchi/harness/skills"|".tabnine/agent/skills") [ -d "$(dirname "$(dirname "$_asbd_base")")" ] ;; + ".zcode/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/ZCode.app" ] ;; + ".minimax/skills") [ -d "$(dirname "$_asbd_base")" ] || [ -d "/Applications/MiniMax Code.app" ] ;; + *) [ -d "$(dirname "$_asbd_base")" ] ;; + esac +} + +same_physical_skill_root() { + [ -d "$1" ] && [ -d "$2" ] || return 1 + # CDPATH= and -- are required: an exported CDPATH makes `cd` echo the resolved + # directory into the command substitution, which would silently report two + # identical roots as different. + _sps_left="$(CDPATH= cd -- "$1" 2>/dev/null && pwd -P)" || return 1 + _sps_right="$(CDPATH= cd -- "$2" 2>/dev/null && pwd -P)" || return 1 + [ "$_sps_left" = "$_sps_right" ] +} + +retire_agent_skill_root() { _rgs_root="$1" - _rgs_base="$_rgs_root/.agents/skills" - _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-generic.XXXXXX")" || return 1 + _rgs_base="$2" + _rgs_stage="$(mktemp -d "${TMPDIR:-/tmp}/dws-retire-agent.XXXXXX")" || return 1 _rgs_backups="$_rgs_stage/backups" : > "$_rgs_backups" || { rm -rf "$_rgs_stage"; return 1; } for _rgs_victim in "$_rgs_base/dws" "$_rgs_base"/*; do - [ -d "$_rgs_victim" ] || continue + [ -e "$_rgs_victim" ] || [ -L "$_rgs_victim" ] || continue if [ "$(basename "$_rgs_victim")" != "dws" ] && ! is_managed_multi_skill_dir "$_rgs_victim"; then continue fi @@ -666,6 +1145,136 @@ retire_generic_skill_root() { rm -rf "$_rgs_stage" } +# link_canonical_skills_to_base [bundle-src] +# is the installed multi bundle and is REQUIRED in multi mode: the +# link set must come from the bundle, never from the whole canonical store. +# ~/.agents/skills is now shared, so third-party/user Skills live there too and +# must not be published into every detected Agent root (they are absent from +# skills-state.json, so nothing could ever reclaim those links). Go +# publishLinkedUpgradeTarget and build/npm/install.js pass the bundle skill list +# the same way. +link_canonical_skills_to_base() { + _lcs_root="$1" + _lcs_base="$2" + _lcs_mode="$3" + _lcs_bundle="${4-}" + _lcs_canonical="$_lcs_root/.agents/skills" + mkdir -p "$_lcs_base" || return 1 + same_physical_skill_root "$_lcs_base" "$_lcs_canonical" && return 0 + if [ "$_lcs_mode" = "mono" ]; then + _lcs_names="dws" + else + [ -n "$_lcs_bundle" ] && [ -d "$_lcs_bundle" ] || return 1 + _lcs_names="" + for _lcs_skill in "$_lcs_bundle"/*/; do + [ -f "${_lcs_skill}SKILL.md" ] || continue + _lcs_names="$_lcs_names $(basename "$_lcs_skill")" + done + [ -n "$_lcs_names" ] || return 1 + fi + # Collision pre-flight: the victim loop below deliberately refuses to back up + # entries that are not DWS-managed, and publication must never replace them. + # Report the exact colliding paths before touching anything, so the fall back + # to the legacy full-copy layout is observable and actionable. + _lcs_collision=0 + for _lcs_name in $_lcs_names; do + _lcs_dest="$_lcs_base/$_lcs_name" + { [ -e "$_lcs_dest" ] || [ -L "$_lcs_dest" ]; } || continue + same_physical_skill_root "$_lcs_dest" "$_lcs_canonical/$_lcs_name" && continue + [ "$_lcs_name" = "dws" ] && continue + is_managed_multi_skill_dir "$_lcs_dest" && continue + say " ⚠️ $_lcs_dest 已存在且不是 DWS 安装的 Skill,无法在此建立指向 $_lcs_canonical/$_lcs_name 的共享链接" + _lcs_collision=1 + done + if [ "$_lcs_collision" -eq 1 ]; then + say " ⚠️ 已保留上述目录(不会删除用户数据),该 Agent 改用独立副本方式安装;" + say " 移走或改名后重新运行安装即可切换为共享 $_lcs_canonical 布局。" + return 1 + fi + _lcs_base_real="$(CDPATH= cd -- "$_lcs_base" && pwd -P)" || return 1 + _lcs_stage="$(mktemp -d "$_lcs_base/.dws-link-set.XXXXXX")" || return 1 + _lcs_backups="$_lcs_stage/.backups" + _lcs_published="$_lcs_stage/.published" + _lcs_stage_token="$(basename "$_lcs_stage")" + : > "$_lcs_backups" || { rm -rf "$_lcs_stage"; return 1; } + : > "$_lcs_published" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_publish_names="" + for _lcs_name in $_lcs_names; do + if same_physical_skill_root "$_lcs_base/$_lcs_name" "$_lcs_canonical/$_lcs_name"; then + continue + fi + _lcs_target_real="$(CDPATH= cd -- "$_lcs_canonical/$_lcs_name" && pwd -P)" || { rm -rf "$_lcs_stage"; return 1; } + _lcs_link_target="$(awk -v from="$_lcs_base_real" -v to="$_lcs_target_real" 'BEGIN { nf=split(from,f,"/"); nt=split(to,t,"/"); i=1; while(i<=nf&&i<=nt&&f[i]==t[i])i++; out=""; for(j=i;j<=nf;j++)if(f[j]!="")out=out"../"; for(j=i;j<=nt;j++)if(t[j]!="")out=out t[j](j/dev/null)" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + # Publish by creating the link directly at the destination: symlink(2) + # refuses an occupied path with EEXIST, so the creation itself is the + # atomic no-replace check. `mv` after any pre-check could still replace a + # file or symlink a concurrent writer created in between, and `ln -P` + # stays unusable (absent from BusyBox `ln`, which silently degraded every + # non-universal Agent to the copy layout on Alpine and most containers). + if ! ln -s "$_lcs_link_target" "$_lcs_dest" 2>/dev/null; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + # A directory that appeared at the destination turns `ln -s` into a + # container: the link lands inside it under the target basename. Remove + # exactly our link after an identity check and roll back; the foreign + # directory stays untouched. + if [ ! -L "$_lcs_dest" ]; then + _lcs_nested_name="${_lcs_link_target##*/}" + _lcs_nested_inode="$(skill_link_inode "$_lcs_dest/$_lcs_nested_name" 2>/dev/null)" || _lcs_nested_inode="" + cleanup_nested_staged_link "$_lcs_dest" "$_lcs_nested_name" "$_lcs_link_target" "$_lcs_nested_inode" || true + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + _lcs_inode="$(skill_link_inode "$_lcs_dest")" || { + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + } + if ! skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + if ! printf '%s\n%s\n%s\n' "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode" >> "$_lcs_published"; then + if skill_link_matches "$_lcs_dest" "$_lcs_link_target" "$_lcs_inode"; then rm -f "$_lcs_dest" || true; fi + restore_linked_skill_set "$_lcs_published" "$_lcs_backups" || true + rm -rf "$_lcs_stage" + return 1 + fi + say " ↪ Skills → $_lcs_dest" + done + rm -rf "$_lcs_stage" +} + # Install skill tree into all agent homes (same rules as build/npm/install.js installSkillsToHomes). # Installing mono removes proven DWS-managed multi leftovers for mutual exclusion, # mirroring `dws skill setup --mode mono`. @@ -673,90 +1282,48 @@ install_skills_to_homes() { skill_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_mono_to_base "$skill_src" "$root/.agents/skills" "~/.agents/skills/$SKILL_NAME"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败"; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - case "$root" in - "$HOME") - label="~/$agent_dir/$SKILL_NAME" - ;; - *) - label="$root/$agent_dir/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$base_dir" "$label"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" mono; then installed=$((installed + 1)) else - failed=$((failed + 1)) + if _install_mono_to_base "$skill_src" "$base_dir" "$base_dir/$SKILL_NAME"; then + say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ]; then - case "$root" in - "$HOME") - flabel="~/.agents/skills/$SKILL_NAME" - ;; - *) - flabel="$root/.agents/skills/$SKILL_NAME" - ;; - esac - if _install_mono_to_base "$skill_src" "$root/.agents/skills" "$flabel"; then - installed=$((installed + 1)) - else - failed=$((failed + 1)) - fi - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 mono Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装 mono Skill 失败" return 1 fi rm -f "$SKILL_STATE_ROOT/skills-state.json" + say "✅ DWS Skills 安装完成" + say " 统一安装位置:$root/.agents/skills" + say " 已自动适配本机上检测到的 Agent" + say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" } # multi_tree_has_skills returns 0 only when the given multi bundle directory @@ -783,71 +1350,48 @@ install_multi_skills_to_homes() { multi_src="$1" root="${HOME}" installed=0 - attempted=0 + attempted=1 failed=0 - idx=0 - specific_agents=0 - for specific_dir in \ - ".claude/skills" ".cursor/skills" ".qoder/skills" ".qoderwork/skills" \ - ".gemini/skills" ".codex/skills" ".zcode/skills" ".github/skills" ".windsurf/skills" \ - ".augment/skills" ".cline/skills" ".amp/skills" ".kiro/skills" \ - ".trae/skills" ".openclaw/skills" ".hermes/skills" + retire_failed=0 + if _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then installed=1; else failed=1; fi + [ "$installed" -gt 0 ] || { say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败"; return 1; } + for agent_dir in $(agent_skill_dirs) do - [ -e "$root/$(dirname "$specific_dir")" ] && specific_agents=$((specific_agents + 1)) - done - for agent_dir in \ - ".agents/skills" \ - ".claude/skills" \ - ".cursor/skills" \ - ".qoder/skills" \ - ".qoderwork/skills" \ - ".gemini/skills" \ - ".codex/skills" \ - ".zcode/skills" \ - ".github/skills" \ - ".windsurf/skills" \ - ".augment/skills" \ - ".cline/skills" \ - ".amp/skills" \ - ".kiro/skills" \ - ".trae/skills" \ - ".openclaw/skills" \ - ".hermes/skills" - do - if [ "$idx" -eq 0 ] && [ "$specific_agents" -gt 0 ]; then - idx=$((idx + 1)) - continue - fi - base_dir="$root/$agent_dir" - parent_gate="$(dirname "$base_dir")" - if [ "$idx" -gt 0 ] && [ ! -e "$parent_gate" ]; then - idx=$((idx + 1)) - continue - fi + base_dir="$(resolve_agent_skill_base "$root" "$agent_dir")" + agent_skill_base_detected "$agent_dir" "$base_dir" || continue + same_physical_skill_root "$base_dir" "$root/.agents/skills" && continue attempted=$((attempted + 1)) - if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + if is_universal_agent_dir "$agent_dir"; then + retire_agent_skill_root "$root" "$base_dir" || retire_failed=$((retire_failed + 1)) + continue + fi + if link_canonical_skills_to_base "$root" "$base_dir" multi "$multi_src"; then installed=$((installed + 1)) else - failed=$((failed + 1)) - say " ⚠️ 跳过 ${base_dir}(备份或复制失败,未完成 multi 安装)" + if _install_multi_to_base "$multi_src" "$base_dir" "$root" "$agent_dir"; then + say " ℹ️ $base_dir 已自动使用兼容方式安装,可正常使用" + installed=$((installed + 1)) + else + failed=$((failed + 1)) + fi fi - idx=$((idx + 1)) done - if [ "$specific_agents" -gt 0 ] && [ "$installed" -gt 0 ]; then - retire_generic_skill_root "$root" || failed=$((failed + 1)) - fi - if [ "$attempted" -eq 0 ] && _install_multi_to_base "$multi_src" "$root/.agents/skills" "$root" ".agents/skills"; then - installed=$((installed + 1)) - fi if [ "$installed" -eq 0 ]; then say " ⚠️ 未安装任何 multi Skill:所有检测到的 Agent 目标均失败" return 1 fi + if [ "$retire_failed" -gt 0 ]; then + printf ' ⚠️ 有 %s 个 Agent 旧副本未能迁移(安装已完成,可稍后手动删除)\n' "$retire_failed" + fi if [ "$failed" -gt 0 ]; then say " ⚠️ 有 ${failed} 个 Agent 目标安装失败" return 1 fi write_skills_state "$multi_src" "install.sh" || return 1 + say "✅ DWS Skills 安装完成" + say " 统一安装位置:$root/.agents/skills" + say " 已自动适配本机上检测到的 Agent" + say "ℹ️ 下一步:请重启已打开的 Agent,使新 Skills 生效" } _install_multi_to_base() { @@ -922,12 +1466,7 @@ _install_multi_to_base() { [ -f "${skill_dir}SKILL.md" ] || continue _name="$(basename "$skill_dir")" _dest="$_base/$_name" - printf '%s\n' "$_dest" >> "$_ms_published" || { - restore_multi_skill_set "$_ms_published" "$_ms_backups" || true - rm -rf "$_ms_stage" - return 1 - } - if ! mv "$_ms_stage/$_name" "$_dest"; then + if ! publish_skill_dir_no_replace "$_ms_stage/$_name" "$_dest" "$_ms_published"; then say " ⚠️ multi Skill 集合发布失败,正在恢复原集合: $_dest" restore_multi_skill_set "$_ms_published" "$_ms_backups" || say " ⚠️ 原 Skill 集合自动恢复不完整,请检查上方备份路径" rm -rf "$_ms_stage" @@ -1018,32 +1557,7 @@ install_binary() { download "$download_url" "$tmpdir/$archive_name" - # Download and verify SHA256 checksum - checksum_url="$(asset_url checksums.txt)" - if download "$checksum_url" "$tmpdir/checksums.txt" 2>/dev/null; then - expected="$(awk -v file="$archive_name" '$2 == file {print $1; exit}' "$tmpdir/checksums.txt")" - if [ -n "$expected" ]; then - if need_cmd sha256sum; then - actual="$(sha256sum "$tmpdir/$archive_name" | awk '{print $1}')" - elif need_cmd shasum; then - actual="$(shasum -a 256 "$tmpdir/$archive_name" | awk '{print $1}')" - else - actual="" - fi - if [ -n "$actual" ] && [ "$actual" != "$expected" ]; then - err "SHA256 checksum mismatch! Expected ${expected}, got ${actual}. Aborting." - fi - if [ -n "$actual" ]; then - say "✅ SHA256 checksum verified" - else - say "⚠️ Could not compute checksum (sha256sum/shasum not found); skipping verification" - fi - else - say "⚠️ Archive not found in checksums.txt; skipping verification" - fi - else - say "⚠️ Could not download checksums.txt; skipping verification" - fi + verify_release_asset_checksum "$archive_name" "$tmpdir/$archive_name" "$tmpdir" say "📦 Extracting..." tar xzf "$tmpdir/$archive_name" -C "$tmpdir" @@ -1108,6 +1622,8 @@ install_skills() { fi fi + verify_release_asset_checksum "$skills_archive" "$tmpdir_skills/$skills_archive" "$tmpdir_skills" + extract_root="$tmpdir_skills/skills" mkdir -p "$extract_root" if ! extract_zip "$tmpdir_skills/$skills_archive" "$extract_root" 2>/dev/null; then @@ -1206,6 +1722,10 @@ main() { install_skills fi + # Every transaction of this run has finished, so old stamped archives can no + # longer be needed for a rollback. + prune_skill_backups + printf '\n' say "🎉 Installation complete!" say "" diff --git a/scripts/policy/check-authoritative-schema-compatibility.sh b/scripts/policy/check-authoritative-schema-compatibility.sh index 16453ad4..f7a5b5b3 100755 --- a/scripts/policy/check-authoritative-schema-compatibility.sh +++ b/scripts/policy/check-authoritative-schema-compatibility.sh @@ -13,8 +13,13 @@ STABLE_REF="" CANDIDATE_REF="HEAD" SCHEMA_CHECKER_REL="scripts/policy/schema-compat/main.go" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" MIGRATIONS_REL="internal/interfacesnapshot/migrations.go" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref [--stable-ref ] [--candidate-ref ]" >&2 @@ -144,6 +149,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -173,6 +189,94 @@ has_any_schema_migration_governance_artifact() { return 1 } +has_complete_schema_command_migration_governance() { + governance_commit="$1" + has_complete_schema_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_schema_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'error: candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -225,10 +329,12 @@ if ! has_complete_schema_migration_governance "$CANDIDATE_COMMIT"; then exit 2 fi check_candidate_alias_source_policy +check_candidate_const_params_source_policy EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false if has_complete_schema_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -252,6 +358,27 @@ else fi fi +if has_complete_schema_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_schema_command_governance + require_base_identical_command_migration_bridges +elif has_any_schema_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'error: merge-base contains an incomplete Schema command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_schema_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap keeps the old base-owned checker in control and therefore grants + # no command migration authorization in the governance PR itself. + require_complete_candidate_schema_command_governance +fi + +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$BASE_WORKTREE" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$BASE_WORKTREE" "$STABLE_WORKTREE" +fi + BASE_BIN="$TMP_ROOT/base-dws" STABLE_BIN="$TMP_ROOT/stable-dws" CANDIDATE_BIN="$TMP_ROOT/candidate-dws" @@ -276,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" @@ -337,16 +469,27 @@ generate_interface_snapshot "$CANDIDATE_WORKTREE" "$CURRENT_INTERFACE_SNAPSHOT" generate_interface_snapshot "$BASE_WORKTREE" "$BASE_INTERFACE_SNAPSHOT" generate_interface_snapshot "$STABLE_WORKTREE" "$STABLE_INTERFACE_SNAPSHOT" -check_schema_contract "PR merge-base" "$BASE_REF" "$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" +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 + set -- "$@" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --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_schema_contract "stable" "$STABLE_REF" "$STABLE_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" +check_with_migrations "PR merge-base" "$BASE_REF" "$BASELINE" +check_with_migrations "stable" "$STABLE_REF" "$STABLE_BASELINE" diff --git a/scripts/policy/check-command-compatibility.sh b/scripts/policy/check-command-compatibility.sh index d26a7abd..2292bd77 100755 --- a/scripts/policy/check-command-compatibility.sh +++ b/scripts/policy/check-command-compatibility.sh @@ -12,7 +12,12 @@ BASE_REF="" STABLE_REF="" CANDIDATE_REF="HEAD" MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-flag-migrations-v1.json" +COMMAND_MIGRATION_MANIFEST_REL="scripts/policy/interface-migrations/approved-command-migrations-v1.json" +COMMAND_MIGRATIONS_REL="internal/interfacesnapshot/command_migrations.go" +CORECMD_BRIDGE_REL="internal/corecmd/corecmd.go" ALIAS_CONTRACT_REL="internal/corecmd/runtimeannotate/interface_alias.go" +CONST_PARAMS_REGISTRY_REL="internal/corecmd/interface_const_params.go" +LEAF_ADAPTER_REL="internal/helpers/leaf.go" usage() { printf '%s\n' "usage: $0 --base-ref --stable-ref [--candidate-ref ]" >&2 @@ -130,6 +135,17 @@ commit_path_is_regular_file() { [ "$2" = blob ] && [ "$4" = "$tree_path" ] } +commit_path_blob() { + tree_commit="$1" + tree_path="$2" + tree_entry="$(git -C "$ROOT" ls-tree "$tree_commit" -- "$tree_path")" + [ -n "$tree_entry" ] || return 1 + set -- $tree_entry + [ "$#" -eq 4 ] || return 1 + [ "$2" = blob ] && [ "$4" = "$tree_path" ] || return 1 + printf '%s\n' "$3" +} + has_modern_interface_helper() { governance_commit="$1" commit_path_is_regular_file "$governance_commit" cmd/interface-snapshot/main.go && @@ -166,6 +182,84 @@ require_complete_candidate_governance() { fi } +has_complete_command_migration_governance() { + governance_commit="$1" + has_complete_migration_governance "$governance_commit" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATIONS_REL" && + commit_path_is_regular_file "$governance_commit" "$CONST_PARAMS_REGISTRY_REL" && + commit_path_is_regular_file "$governance_commit" "$COMMAND_MIGRATION_MANIFEST_REL" +} + +has_any_command_migration_governance_artifact() { + governance_commit="$1" + for relative_path in "$COMMAND_MIGRATIONS_REL" "$CONST_PARAMS_REGISTRY_REL" "$COMMAND_MIGRATION_MANIFEST_REL"; do + if commit_path_exists "$governance_commit" "$relative_path"; then + return 0 + fi + done + return 1 +} + +check_candidate_const_params_source_policy() { + source_policy_failed=false + for token in attachInterfaceBoolConstParams InterfaceBoolConstParams interfaceBoolConstParamsRegistry; do + matches="$(git -C "$CANDIDATE_WORKTREE" grep -l -w -F -e "$token" -- '*.go' || true)" + [ -z "$matches" ] && continue + while IFS= read -r relative_path; do + [ -n "$relative_path" ] || continue + case "$relative_path" in + *_test.go) + continue + ;; + internal/corecmd/interface_const_params.go) + continue + ;; + internal/corecmd/corecmd.go) + [ "$token" = attachInterfaceBoolConstParams ] && continue + ;; + internal/interfacesnapshot/snapshot.go) + [ "$token" = InterfaceBoolConstParams ] && continue + ;; + esac + if [ "$source_policy_failed" = false ]; then + printf 'candidate production source may not access framework-owned bool ConstParams registry:\n' >&2 + fi + printf ' %s: protected bool ConstParams registry identifier %s\n' "$relative_path" "$token" >&2 + source_policy_failed=true + done <&2 + exit 2 + fi +} + +require_base_identical_command_migration_bridges() { + base_command_manifest_blob="$(commit_path_blob "$BASE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + candidate_command_manifest_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$COMMAND_MIGRATION_MANIFEST_REL")" + [ "$base_command_manifest_blob" != "$candidate_command_manifest_blob" ] || return 0 + + for protected_bridge_path in "$CORECMD_BRIDGE_REL" "$CONST_PARAMS_REGISTRY_REL" "$LEAF_ADAPTER_REL"; do + base_protected_bridge_blob="$(commit_path_blob "$BASE_COMMIT" "$protected_bridge_path" || true)" + candidate_protected_bridge_blob="$(commit_path_blob "$CANDIDATE_COMMIT" "$protected_bridge_path" || true)" + if [ -z "$base_protected_bridge_blob" ] || + [ "$candidate_protected_bridge_blob" != "$base_protected_bridge_blob" ]; then + printf 'candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: %s\n' \ + "$protected_bridge_path" >&2 + exit 2 + fi + done +} + check_candidate_alias_source_policy() { source_policy_failed=false for token in \ @@ -222,9 +316,36 @@ install_authority_helper() { fi } +install_authority_const_params_registry() { + source_root="$1" + worktree="$2" + + [ "$source_root" = "$worktree" ] && return + mkdir -p "$worktree/$(dirname "$CONST_PARAMS_REGISTRY_REL")" + rm -f "$worktree/$CONST_PARAMS_REGISTRY_REL" + cp "$source_root/$CONST_PARAMS_REGISTRY_REL" "$worktree/$CONST_PARAMS_REGISTRY_REL" +} + EMPTY_MANIFEST="$TMP_ROOT/empty-migrations.json" printf '%s\n' '{"version":1,"migrations":[]}' >"$EMPTY_MANIFEST" USE_MIGRATION_GOVERNANCE=false +USE_COMMAND_MIGRATION_GOVERNANCE=false + +if has_complete_command_migration_governance "$BASE_COMMIT"; then + USE_COMMAND_MIGRATION_GOVERNANCE=true + APPROVED_COMMAND_MANIFEST="$BASE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + CANDIDATE_COMMAND_MANIFEST="$CANDIDATE_WORKTREE/$COMMAND_MIGRATION_MANIFEST_REL" + require_complete_candidate_command_governance + require_base_identical_command_migration_bridges +elif has_any_command_migration_governance_artifact "$BASE_COMMIT"; then + printf 'merge-base contains an incomplete command migration governance artifact set: %s\n' \ + "$BASE_REF" >&2 + exit 2 +elif has_any_command_migration_governance_artifact "$CANDIDATE_COMMIT"; then + # Bootstrap is safe because the base-owned comparator receives no new + # authorization input and still performs the ordinary compatibility check. + require_complete_candidate_command_governance +fi if has_complete_migration_governance "$BASE_COMMIT"; then USE_MIGRATION_GOVERNANCE=true @@ -233,6 +354,7 @@ if has_complete_migration_governance "$BASE_COMMIT"; then CANDIDATE_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy install_authority_helper "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" true else @@ -252,6 +374,7 @@ else AUTHORITY_ROOT="$BASE_WORKTREE" require_complete_candidate_governance check_candidate_alias_source_policy + check_candidate_const_params_source_policy BOOTSTRAP_MANIFEST="$CANDIDATE_WORKTREE/$MIGRATION_MANIFEST_REL" if ! cmp -s "$BOOTSTRAP_MANIFEST" "$EMPTY_MANIFEST"; then printf 'initial flag migration governance requires the canonical empty manifest: %s\n' "$BOOTSTRAP_MANIFEST" >&2 @@ -262,6 +385,11 @@ fi install_authority_helper "$AUTHORITY_ROOT" "$BASE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" install_authority_helper "$AUTHORITY_ROOT" "$STABLE_WORKTREE" "$USE_MIGRATION_GOVERNANCE" +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + install_authority_const_params_registry "$AUTHORITY_ROOT" "$CANDIDATE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$BASE_WORKTREE" + install_authority_const_params_registry "$AUTHORITY_ROOT" "$STABLE_WORKTREE" +fi generate_snapshot() { worktree="$1" @@ -280,7 +408,19 @@ generate_snapshot "$CANDIDATE_WORKTREE" "$CANDIDATE" generate_snapshot "$BASE_WORKTREE" "$BASELINE" generate_snapshot "$STABLE_WORKTREE" "$STABLE" -if [ "$USE_MIGRATION_GOVERNANCE" = true ]; then +if [ "$USE_COMMAND_MIGRATION_GOVERNANCE" = true ]; then + ( + cd "$AUTHORITY_ROOT" + go run ./cmd/interface-snapshot compare \ + --current "$CANDIDATE" \ + --base "$BASELINE" \ + --stable "$STABLE" \ + --approved-flag-migrations "$APPROVED_MANIFEST" \ + --candidate-flag-migrations "$CANDIDATE_MANIFEST" \ + --approved-command-migrations "$APPROVED_COMMAND_MANIFEST" \ + --candidate-command-migrations "$CANDIDATE_COMMAND_MANIFEST" + ) +elif [ "$USE_MIGRATION_GOVERNANCE" = true ]; then ( cd "$AUTHORITY_ROOT" go run ./cmd/interface-snapshot compare \ diff --git a/scripts/policy/check-release-fragments.sh b/scripts/policy/check-release-fragments.sh index a93f113a..a617831e 100755 --- a/scripts/policy/check-release-fragments.sh +++ b/scripts/policy/check-release-fragments.sh @@ -68,7 +68,28 @@ if [ "$archive_changed" = true ]; then index($0, heading) == 1 { found = 1; next } found && /^## / { exit } found { print } - ' >"$tmp_root/actual-notes" + ' >"$tmp_root/actual-section" + case "$release_version" in + *-beta.*) + cp "$tmp_root/actual-section" "$tmp_root/actual-notes" + ;; + *) + stable_beta="$(sed -n 's/^### Changes since `\(v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*\)`$/\1/p' "$tmp_root/actual-section")" + [ "$(printf '%s\n' "$stable_beta" | sed '/^$/d' | wc -l | tr -d '[:space:]')" -eq 1 ] || { + printf '%s\n' 'error: stable release-seal with archived fragments requires exactly one ### Changes since `vX.Y.Z-beta.N` boundary' >&2 + exit 1 + } + . "$ROOT/scripts/release/release-lib.sh" + [ "$(release_core_tag "$stable_beta")" = "v$release_version" ] || { + printf 'error: stable post-beta fragment boundary %s does not match release v%s\n' "$stable_beta" "$release_version" >&2 + exit 1 + } + awk ' + found { print } + /^### Changes since `v[0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*-beta\.[1-9][0-9]*`$/ { found = 1 } + ' "$tmp_root/actual-section" >"$tmp_root/actual-notes" + ;; + esac normalize_notes() { awk ' /^[[:space:]]*$/ && !started { next } diff --git a/scripts/policy/commandcompat/authority_integration_test.go b/scripts/policy/commandcompat/authority_integration_test.go index 9f1ddd8b..494a50e2 100644 --- a/scripts/policy/commandcompat/authority_integration_test.go +++ b/scripts/policy/commandcompat/authority_integration_test.go @@ -51,6 +51,8 @@ func main() { base := flags.String("base", "", "base snapshot") stable := flags.String("stable", "", "stable snapshot") _ = flags.String("approved-flag-migrations", "", "approved manifest") + _ = flags.String("approved-command-migrations", "", "approved command manifest") + _ = flags.String("candidate-command-migrations", "", "candidate command manifest") _ = flags.Parse(os.Args[2:]) for _, path := range []string{*current, *base, *stable} { content, err := os.ReadFile(path) @@ -97,6 +99,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const commandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-command-compat-authority-test/internal/corecmd" + "example.com/dws-command-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const baseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -121,6 +144,54 @@ const ( ) ` +func constParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const baseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const forgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const baseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const forgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -131,11 +202,13 @@ func main() { ` type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - want string - wantAuthorityDecision bool + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + want string + wantAuthorityDecision bool } func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -150,6 +223,85 @@ func TestCrossPlatformCoverageCommandCompatibilityUsesBaseOwnedAuthority(t *test baseGovernance: "none", wantAuthorityDecision: true, }, + { + name: "command governed base owns bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + wantAuthorityDecision: true, + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "bootstrap", + wantAuthorityDecision: true, + }, + { + name: "command governed candidate cannot delete bool ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + wantAuthorityDecision: true, + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + wantAuthorityDecision: true, + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -274,6 +426,19 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), commandGovernedSnapshotSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), baseCorecmdBridgeSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("BASE"), 0o644) + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), baseLeafAdapterSource, 0o644) + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } run(t, fixtureRoot, "git", "add", ".") run(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(run(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -302,6 +467,17 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { 0o644, ) } + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + writeFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } switch test.candidateMutation { case "": case "delete-alias-contract": @@ -314,6 +490,34 @@ func runBaseOwnedAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + writeFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), forgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + writeFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), constParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + writeFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), forgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") diff --git a/scripts/policy/interface-migrations/approved-command-migrations-v1.json b/scripts/policy/interface-migrations/approved-command-migrations-v1.json new file mode 100644 index 00000000..52a6bff1 --- /dev/null +++ b/scripts/policy/interface-migrations/approved-command-migrations-v1.json @@ -0,0 +1,91 @@ +{ + "version": 1, + "migrations": [ + { + "kind": "flag_extraction", + "legacy": { + "command": "dws chat group create", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true} + }, + "replacement": { + "command": "dws chat topic create", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "legacy_flag": { + "name": "thread", + "before": {"present": true, "type": "bool", "no_opt": "true", "scope": "local"}, + "after": {"present": true, "type": "bool", "hidden": true, "no_opt": "true", "scope": "local"} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.create_group_conversation", + "replacement_tool_id": "chat.create_topic_conversation", + "parameters": [ + {"from": "name", "to": "name"}, + {"from": "type", "to": "type"}, + {"from": "users", "to": "users"}, + { + "from": "thread", + "replacement_constant": { + "property": "convThreadEnabled", + "value": true + } + } + ] + }, + "state": "pending", + "reason": "Move topic-conversation creation to the dedicated chat topic command while retaining the legacy --thread execution path." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message list-topic-replies", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic list-replies", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.list_topic_replies", + "replacement_tool_id": "chat.list_topic_replies", + "parameters": [ + {"from": "conversation-id", "to": "open-topic-id"}, + {"from": "topic-id", "to": "open-conv-thread-id"} + ] + }, + "state": "pending", + "reason": "Move topic reply listing from chat message to the dedicated chat topic command while preserving the legacy executable command." + }, + { + "kind": "command_move", + "legacy": { + "command": "dws chat message forward-topic", + "before": {"present": true, "runnable": true}, + "after": {"present": true, "runnable": true, "hidden": true} + }, + "replacement": { + "command": "dws chat topic forward", + "before": {"present": false}, + "after": {"present": true, "runnable": true} + }, + "schema": { + "product_id": "chat", + "source_tool_id": "chat.forward_topic", + "replacement_tool_id": "chat.forward_topic", + "parameters": [ + {"from": "src-conversation-id", "to": "src-open-topic-id"}, + {"from": "src-thread-id", "to": "src-open-conv-thread-id"}, + {"from": "dest-conversation-id", "to": "dest-open-conversation-id"} + ] + }, + "state": "pending", + "reason": "Move topic forwarding from chat message to the dedicated chat topic command while preserving the legacy executable command." + } + ] +} diff --git a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json index 6ddbafb7..e00cf936 100644 --- a/scripts/policy/interface-migrations/approved-flag-migrations-v1.json +++ b/scripts/policy/interface-migrations/approved-flag-migrations-v1.json @@ -1,39 +1,6 @@ { "version": 1, "migrations": [ - { - "command": "dws chat group audit-join-validation", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws chat group bots", "legacy": { @@ -67,1065 +34,6 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws chat group dismiss", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group invite-url", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice create", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice edit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice get", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group notice list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group quit", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-admin", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group set-history", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group transfer-owner", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-alias", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-icon", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-nick", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group update-settings", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group upgrade-to-external", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role add", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role list", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role query-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role remove", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role set-user", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat group-role update", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message edit", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message forward", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message list-topic-replies", - "legacy": { - "name": "group", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "conversation-id" - } - }, - "canonical": { - "name": "conversation-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message recall", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message send", - "legacy": { - "name": "uuid", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "idempotency-key" - } - }, - "canonical": { - "name": "idempotency-key", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": false - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将幂等参数规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message set-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-pin-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message unset-top-msg", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, - { - "command": "dws chat message update-text-emotion", - "legacy": { - "name": "msg-id", - "before": { - "present": true, - "type": "string", - "scope": "local", - "required": true - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "hidden": true, - "alias_of": "message-id" - } - }, - "canonical": { - "name": "message-id", - "before": { - "present": false - }, - "after": { - "present": true, - "type": "string", - "scope": "local", - "required": true - } - }, - "state": "consumed", - "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" - }, { "command": "dws ding message send-by-message", "legacy": { @@ -1159,227 +67,33 @@ "state": "pending", "reason": "保留旧 argv 兼容性,并将 IM ID 规范 flag 设为唯一可见入口" }, - { - "command": "dws aisearch", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws aisearch person", - "legacy": { - "name": "keyword", - "before": {"present": true, "type": "string", "shorthand": "w", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "shorthand": "w", "scope": "local", "alias_of": "query"} - }, - "canonical": { - "name": "query", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +broadcast", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +dm", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-batch-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-bot", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +messages-send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat +send-to-group", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "conversation-id", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "group"} - }, - "canonical": { - "name": "group", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message reply", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": true, "type": "string", "hidden": true, "scope": "local"}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, { "command": "dws chat message send-by-bot", "legacy": { "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "file" + } }, "canonical": { "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." @@ -1388,91 +102,32 @@ "command": "dws chat message send-by-bot", "legacy": { "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} + "before": { + "present": true, + "type": "string", + "scope": "local" + }, + "after": { + "present": true, + "type": "string", + "hidden": true, + "scope": "local", + "alias_of": "content" + } }, "canonical": { "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} + "before": { + "present": false + }, + "after": { + "present": true, + "type": "string", + "scope": "local" + } }, "state": "pending", "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws chat message send-by-webhook", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "required": true, "scope": "local"}, - "after": {"present": true, "type": "string", "required": false, "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "required": true, "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc +doc-append", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block insert", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws doc block update", - "legacy": { - "name": "text", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "content"} - }, - "canonical": { - "name": "content", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." - }, - { - "command": "dws todo task add-attachment", - "legacy": { - "name": "file-path", - "before": {"present": true, "type": "string", "scope": "local"}, - "after": {"present": true, "type": "string", "hidden": true, "scope": "local", "alias_of": "file"} - }, - "canonical": { - "name": "file", - "before": {"present": false}, - "after": {"present": true, "type": "string", "scope": "local"} - }, - "state": "consumed", - "reason": "Preserve legacy argv compatibility while making the canonical flag the only visible parameter." } ] } diff --git a/scripts/policy/schema-compat/command_migrations_test.go b/scripts/policy/schema-compat/command_migrations_test.go new file mode 100644 index 00000000..613d449d --- /dev/null +++ b/scripts/policy/schema-compat/command_migrations_test.go @@ -0,0 +1,1588 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); + +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot" +) + +func TestCrossPlatformCoverageSchemaCommandMigrationsAuthorizeOnlyExactProjection(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("normalize exact command migrations: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact command migrations remained incompatible: %v", failures) + } + + unrelated := cloneContract(current) + product := unrelated.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "keep") + product.Tools["chat.move"] = tool + unrelated.Products["chat"] = product + normalized, err = normalizeSchemaCommandMigrations(baseline, unrelated, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, unrelated), "\n"); !strings.Contains(failures, `lost parameter "keep"`) { + t.Fatalf("unrelated parameter removal was hidden: %s", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationsFailClosedOnDrift(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + parameterDrift := schemaCommandMigrationContract(true) + product := parameterDrift.Products["chat"] + tool := product.Tools["chat.move"] + parameter := tool.Parameters["new-id"] + parameter.Property = "differentProperty" + tool.Parameters["new-id"] = parameter + product.Tools["chat.move"] = tool + parameterDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, parameterDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed a non-name field") { + t.Fatalf("parameter drift error=%v", err) + } + + safetyDrift := schemaCommandMigrationContract(true) + product = safetyDrift.Products["chat"] + replacement := product.Tools["chat.create_topic"] + replacement.Risk = "high" + product.Tools["chat.create_topic"] = replacement + safetyDrift.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, safetyDrift, migrations); err == nil || !strings.Contains(err.Error(), "changed interface or safety identity") { + t.Fatalf("extraction safety drift error=%v", err) + } + + missingReplacement := schemaCommandMigrationContract(true) + product = missingReplacement.Products["chat"] + delete(product.Tools, "chat.create_topic") + missingReplacement.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacement, migrations); err == nil || !strings.Contains(err.Error(), "lacks replacement Schema tool") { + t.Fatalf("missing replacement error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsUnregisteredRequiredParameters(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + migrations := schemaCommandMigrationAuthorizations() + + for _, test := range []struct { + name string + parameter parameterSchema + }{ + {name: "required", parameter: parameterSchema{Type: `"string"`, Property: "must", Required: true}}, + {name: "cli_required", parameter: parameterSchema{Type: `"string"`, Property: "must", CLIRequired: true}}, + {name: "required_when", parameter: parameterSchema{Type: `"string"`, Property: "must", RequiredWhen: "mode=forced"}}, + } { + t.Run(test.name, func(t *testing.T) { + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["must"] = test.parameter + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required parameter error=%v", err) + } + }) + } + + current := schemaCommandMigrationContract(true) + product := current.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["optional"] = parameterSchema{Type: `"string"`, Property: "optional"} + product.Tools["chat.move"] = tool + current.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err != nil { + t.Fatalf("optional addition should remain compatible: %v", err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("optional addition should remain compatible: %v", failures) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationRejectsParameterTargetCollision(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + migrations[0].Schema.Parameters = []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "keep"}} + + product := current.Products["chat"] + tool := product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + tool.Parameters["keep"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + tool.Constraints = baseline.Products["chat"].Tools["chat.move"].Constraints + product.Tools["chat.move"] = tool + current.Products["chat"] = product + + normalized, err := normalizeSchemaCommandMigrations(baseline, current, migrations) + if err == nil { + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("hostile target-collision fixture should otherwise pass compatibility: %v", failures) + } + t.Fatal("parameter target collision was accepted") + } + if !strings.Contains(err.Error(), `target "keep" already exists in historical Schema tool`) { + t.Fatalf("parameter target collision error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationNormalizationEdges(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + + missingHistorical := cloneContract(baseline) + product := missingHistorical.Products["chat"] + delete(product.Tools, "chat.move") + missingHistorical.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistorical, current, migrations); err != nil { + t.Fatalf("missing historical tool should be a no-op: %v", err) + } + + missingCurrent := cloneContract(current) + product = missingCurrent.Products["chat"] + delete(product.Tools, "chat.move") + missingCurrent.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingCurrent, migrations); err != nil { + t.Fatalf("missing current source should remain for ordinary checker: %v", err) + } + + alreadyAfter := schemaCommandMigrationContract(true) + if _, err := normalizeSchemaCommandMigrations(alreadyAfter, current, migrations[:1]); err != nil { + t.Fatalf("already-after baseline should be a no-op: %v", err) + } + + wrongHistoricalPath := cloneContract(baseline) + product = wrongHistoricalPath.Products["chat"] + tool := product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongHistoricalPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(wrongHistoricalPath, current, migrations); err == nil || !strings.Contains(err.Error(), "historical Schema tool") { + t.Fatalf("wrong historical path error=%v", err) + } + + wrongCurrentPath := cloneContract(current) + product = wrongCurrentPath.Products["chat"] + tool = product.Tools["chat.move"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.move"] = tool + wrongCurrentPath.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, wrongCurrentPath, migrations); err != nil { + t.Fatalf("wrong current path should remain for ordinary checker: %v", err) + } + + missingHistoricalParameter := cloneContract(baseline) + product = missingHistoricalParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "old-id") + product.Tools["chat.move"] = tool + missingHistoricalParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(missingHistoricalParameter, current, migrations); err == nil || !strings.Contains(err.Error(), "lacks parameter") { + t.Fatalf("missing historical parameter error=%v", err) + } + + legacyPublished := cloneContract(current) + product = legacyPublished.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["old-id"] = baseline.Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, legacyPublished, migrations); err == nil || !strings.Contains(err.Error(), "still publishes legacy") { + t.Fatalf("legacy parameter error=%v", err) + } + + missingReplacementParameter := cloneContract(current) + product = missingReplacementParameter.Products["chat"] + tool = product.Tools["chat.move"] + delete(tool.Parameters, "new-id") + product.Tools["chat.move"] = tool + missingReplacementParameter.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, missingReplacementParameter, migrations); err == nil || !strings.Contains(err.Error(), "does not publish replacement") { + t.Fatalf("missing replacement parameter error=%v", err) + } + + constraintDrift := cloneContract(current) + product = constraintDrift.Products["chat"] + tool = product.Tools["chat.move"] + tool.Constraints = `{"require_one_of":[["new-id"]]}` + product.Tools["chat.move"] = tool + constraintDrift.Products["chat"] = product + normalized, err := normalizeSchemaCommandMigrations(baseline, constraintDrift, migrations) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, constraintDrift), "\n"); !strings.Contains(failures, "changed constraints") { + 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"] + tool.PrimaryCLIPath = "chat unrelated" + product.Tools["chat.create_group"] = tool + extractionWrongSource.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionWrongSource, migrations[1:]); err != nil { + t.Fatalf("wrong extraction source path should remain for ordinary checker: %v", err) + } + + extractionHistoricalMissing := cloneContract(baseline) + product = extractionHistoricalMissing.Products["chat"] + tool = product.Tools["chat.create_group"] + delete(tool.Parameters, "thread") + product.Tools["chat.create_group"] = tool + extractionHistoricalMissing.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(extractionHistoricalMissing, current, migrations[1:]); err == nil || !strings.Contains(err.Error(), "historical Schema tool lacks") { + t.Fatalf("missing extracted historical parameter error=%v", err) + } + + extractionStillPublished := cloneContract(current) + product = extractionStillPublished.Products["chat"] + tool = product.Tools["chat.create_group"] + tool.Parameters["thread"] = baseline.Products["chat"].Tools["chat.create_group"].Parameters["thread"] + product.Tools["chat.create_group"] = tool + extractionStillPublished.Products["chat"] = product + if _, err := normalizeSchemaCommandMigrations(baseline, extractionStillPublished, migrations[1:]); err == nil || !strings.Contains(err.Error(), "still publishes extracted") { + t.Fatalf("still-published extraction error=%v", err) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionRequiresCompleteReplacementProjection(t *testing.T) { + t.Run("missing historical mapping", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + parameters := make([]interfacesnapshot.CommandParameterMigration, 0, len(migrations[1].Schema.Parameters)-1) + for _, parameter := range migrations[1].Schema.Parameters { + if parameter.From != "users" { + parameters = append(parameters, parameter) + } + } + migrations[1].Schema.Parameters = parameters + + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), `does not map historical Schema parameter "users"`) { + t.Fatalf("missing historical mapping error=%v", err) + } + }) + + for _, test := range []struct { + name string + mutate func([]interfacesnapshot.CommandMigration) + want string + }{ + { + name: "duplicate historical mapping", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters = append( + migrations[1].Schema.Parameters, + migrations[1].Schema.Parameters[0], + ) + }, + want: "maps historical Schema parameter \"name\" more than once", + }, + { + name: "legacy mapping has two targets", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].To = "thread" + }, + want: "must map to one replacement constant", + }, + { + name: "shared mapping has no target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[0].To = "" + }, + want: "must map to one replacement parameter", + }, + { + name: "duplicate replacement target", + mutate: func(migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[1].To = "name" + }, + want: "is mapped from both \"name\" and \"type\"", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("invalid mapping error=%v, want %q", err, test.want) + } + }) + } + + t.Run("replacement removes historical dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + mutateSchemaCommandMigrationTool(&baseline, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_group", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), "changed or removed dry_run") { + t.Fatalf("replacement dry_run error=%v, want monotonic preservation rejection", err) + } + }) + + t.Run("replacement adds dry run", func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + mutateSchemaCommandMigrationTool(¤t, "chat.create_topic", func(tool *toolSchema) { + tool.DryRun = `{"mode":"native"}` + }) + + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err != nil { + t.Fatalf("additive replacement dry_run was rejected: %v", err) + } + }) + + for _, test := range []struct { + name string + mutate func(*schemaContract) + want string + }{ + { + name: "missing shared parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + delete(tool.Parameters, "users") + }) + }, + want: `does not publish mapped Schema parameter "users"`, + }, + { + name: "shared parameter field drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationParameter(current, "chat.create_topic", "users", func(parameter *parameterSchema) { + parameter.Property = "differentUserIds" + }) + }, + want: "changed a non-name field", + }, + { + name: "extra replacement parameter", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["extra"] = parameterSchema{Type: `"string"`, Property: "extra"} + }) + }, + want: `publishes unmapped Schema parameter "extra"`, + }, + { + name: "constraints drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Constraints = `{"require_one_of":[["name","type"]]}` + }) + }, + want: "changed constraints", + }, + { + name: "positionals drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals[0].Name = "topic-name" + }) + }, + want: "changed positionals", + }, + { + name: "positionals length drift", + mutate: func(current *schemaContract) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Positionals = append(tool.Positionals, positionalSchema{Name: "type", Index: 1, Type: "string"}) + }) + }, + want: "changed positionals", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + test.mutate(¤t) + if _, err := normalizeSchemaCommandMigrations(baseline, current, schemaCommandMigrationAuthorizations()); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement projection error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaFlagExtractionValidatesBooleanConstant(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*schemaContract, *schemaContract, []interfacesnapshot.CommandMigration) + want string + }{ + { + name: "constant property mismatch", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Property = "differentProperty" + }, + want: "constant property", + }, + { + name: "constant false", + mutate: func(_, _ *schemaContract, migrations []interfacesnapshot.CommandMigration) { + migrations[1].Schema.Parameters[3].ReplacementConstant.Value = false + }, + want: "constant true", + }, + { + name: "legacy type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Type = `"string"` + }) + }, + want: "must have boolean type", + }, + { + name: "legacy required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Required = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy cli required", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.CLIRequired = true + }) + }, + want: "must remain optional", + }, + { + name: "legacy required when", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.RequiredWhen = "mode=topic" + }) + }, + want: "must not declare required_when", + }, + { + name: "legacy default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Default = "true" + }) + }, + want: "default must be absent or false", + }, + { + name: "legacy interface default true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceDefault = "true" + }) + }, + want: "interface_default must be absent or false", + }, + { + name: "legacy interface type", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.InterfaceType = "string" + }) + }, + want: "interface_type must be empty or boolean", + }, + { + name: "legacy enum excludes true", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Enum = []string{"false"} + }) + }, + want: "enum must allow true", + }, + { + name: "legacy format", + mutate: func(baseline, _ *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationParameter(baseline, "chat.create_group", "thread", func(parameter *parameterSchema) { + parameter.Format = "custom-bool" + }) + }, + want: "format must be empty", + }, + { + name: "replacement publishes constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_topic", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "still publishes replacement constant property", + }, + { + name: "source renames constant property", + mutate: func(_, current *schemaContract, _ []interfacesnapshot.CommandMigration) { + mutateSchemaCommandMigrationTool(current, "chat.create_group", func(tool *toolSchema) { + tool.Parameters["thread-copy"] = parameterSchema{Type: `"boolean"`, Property: "threadEnabled"} + }) + }, + want: "source Schema tool still publishes replacement constant property", + }, + } { + t.Run(test.name, func(t *testing.T) { + baseline := schemaCommandMigrationContract(false) + current := schemaCommandMigrationContract(true) + migrations := schemaCommandMigrationAuthorizations() + test.mutate(&baseline, ¤t, migrations) + if _, err := normalizeSchemaCommandMigrations(baseline, current, migrations); err == nil || + !strings.Contains(err.Error(), test.want) { + t.Fatalf("replacement constant error=%v, want %q", err, test.want) + } + }) + } +} + +func TestCrossPlatformCoverageSchemaCommandMigrationLifecycleAndRun(t *testing.T) { + directory := t.TempDir() + baselinePath := filepath.Join(directory, "baseline.json") + currentPath := filepath.Join(directory, "current.json") + approvedPath := filepath.Join(directory, "approved.json") + candidatePath := filepath.Join(directory, "candidate.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, schemaCommandMigrationContract(false)) + writeRawSchemaContractFile(t, currentPath, schemaCommandMigrationContract(true)) + writeCommandMigrationManifestFile(t, approvedPath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationPending)) + writeCommandMigrationManifestFile(t, candidatePath, schemaCommandMigrationManifest(interfacesnapshot.CommandMigrationConsumed)) + writeInterfaceSnapshotFile(t, currentSnapshotPath, schemaCommandMigrationSnapshot(true)) + writeInterfaceSnapshotFile(t, baseSnapshotPath, schemaCommandMigrationSnapshot(false)) + writeInterfaceSnapshotFile(t, stableSnapshotPath, schemaCommandMigrationSnapshot(false)) + + args := []string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + "--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("command migration run code=%d stderr=%s", code, stderr.String()) + } + + paths := []string{approvedPath, candidatePath, currentSnapshotPath, baseSnapshotPath, stableSnapshotPath} + for index := range paths { + invalid := append([]string(nil), paths...) + invalid[index] = filepath.Join(directory, "missing.json") + if _, err := authorizeSchemaCommandMigrations(invalid[0], invalid[1], invalid[2], invalid[3], invalid[4]); err == nil { + t.Fatalf("missing command migration input %d was accepted", index) + } + } + + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--approved-command-migrations", approvedPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both command manifests") { + t.Fatalf("partial command pair code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{ + "--check", baselinePath, + "--current", currentPath, + "--approved-command-migrations", approvedPath, + "--candidate-command-migrations", candidatePath, + }, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all three interface snapshots") { + t.Fatalf("missing command snapshots code=%d stderr=%q", code, stderr.String()) + } + stderr.Reset() + if code := run([]string{"--check", baselinePath, "--current", currentPath, "--migration-current-snapshot", currentSnapshotPath}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "require a flag or command") { + t.Fatalf("orphan snapshot code=%d stderr=%q", code, stderr.String()) + } + + badArgs := append([]string(nil), args...) + badArgs[5] = filepath.Join(directory, "missing-approved.json") + stderr.Reset() + if code := run(badArgs, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "authorize Schema command migrations") { + t.Fatalf("command authorization error code=%d stderr=%q", code, stderr.String()) + } + + legacyCurrent := schemaCommandMigrationContract(true) + product := legacyCurrent.Products["chat"] + tool := product.Tools["chat.move"] + tool.Parameters["old-id"] = schemaCommandMigrationContract(false).Products["chat"].Tools["chat.move"].Parameters["old-id"] + product.Tools["chat.move"] = tool + legacyCurrent.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, legacyCurrent) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "normalize approved Schema command migrations") { + t.Fatalf("command normalization error code=%d stderr=%q", code, stderr.String()) + } + + requiredAddition := schemaCommandMigrationContract(true) + product = requiredAddition.Products["chat"] + tool = product.Tools["chat.move"] + tool.Parameters["must"] = parameterSchema{Type: `"string"`, Property: "must", Required: true, CLIRequired: true} + product.Tools["chat.move"] = tool + requiredAddition.Products["chat"] = product + writeRawSchemaContractFile(t, currentPath, requiredAddition) + stderr.Reset() + if code := run(args, &stdout, &stderr); code != 2 || + !strings.Contains(stderr.String(), `introduced unregistered required Schema parameter "must"`) { + t.Fatalf("unregistered required addition code=%d stderr=%q", code, stderr.String()) + } +} + +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) + } + + requiredness := interfacesnapshot.FlagMigration{ + Kind: interfacesnapshot.FlagMigrationRequirednessChange, + Command: "dws report cli-only", + Flag: &interfacesnapshot.FlagMigrationSide{ + Name: "recipient", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + }, + State: interfacesnapshot.FlagMigrationConsumed, + Reason: "Exercise requiredness receipts beside command lineage without treating them as renames.", + } + flagsWithRequiredness := append([]interfacesnapshot.FlagMigration(nil), schemaCommandLineageFlagManifest().Migrations...) + flagsWithRequiredness = append(flagsWithRequiredness, requiredness) + if _, err := normalizeSchemaCommandMigrationLineage( + stable, + base, + current, + flagsWithRequiredness, + schemaCommandLineageManifest(interfacesnapshot.CommandMigrationPending).Migrations, + ); err != nil { + t.Fatalf("requiredness receipt interfered with command lineage: %v", err) + } + + // 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"} + name := parameterSchema{Type: `"string"`, Property: "name"} + groupType := parameterSchema{Type: `"string"`, Property: "type"} + users := parameterSchema{Type: `"array"`, Property: "userIds"} + thread := parameterSchema{ + Type: `"boolean"`, + Property: "threadEnabled", + InterfaceType: "boolean", + Default: "false", + InterfaceDefault: "false", + Enum: []string{"false", "true"}, + } + group := toolSchema{ + PrimaryCLIPath: "chat group create", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"im","rpc_name":"create_group"}`, + Availability: "available", + Parameters: map[string]parameterSchema{ + "name": name, + "thread": thread, + "type": groupType, + "users": users, + }, + Constraints: `{"require_together":[["name","type"]]}`, + Positionals: []positionalSchema{{Name: "name", Index: 0, Type: "string", Required: true}}, + Effect: "write", + Risk: "medium", + Confirmation: "not_required", + Idempotency: "unknown", + } + move := toolSchema{ + PrimaryCLIPath: "chat message old", + InterfaceMode: "mcp", + InterfaceRef: `{"product_id":"chat","rpc_name":"move"}`, + Availability: "available", + Parameters: map[string]parameterSchema{"old-id": id, "keep": keep}, + Constraints: `{"require_together":[["keep","old-id"]]}`, + Effect: "read", + Risk: "low", + Confirmation: "not_required", + Idempotency: "idempotent", + } + tools := map[string]toolSchema{"chat.create_group": group, "chat.move": move} + if after { + delete(group.Parameters, "thread") + tools["chat.create_group"] = group + replacement := group + replacement.Parameters = make(map[string]parameterSchema, len(group.Parameters)) + for name, parameter := range group.Parameters { + replacement.Parameters[name] = parameter + } + replacement.Positionals = append([]positionalSchema(nil), group.Positionals...) + replacement.PrimaryCLIPath = "chat topic create" + tools["chat.create_topic"] = replacement + delete(move.Parameters, "old-id") + move.Parameters["new-id"] = id + move.PrimaryCLIPath = "chat topic new" + move.Constraints = `{"require_together":[["keep","new-id"]]}` + tools["chat.move"] = move + } + 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{ + { + Kind: interfacesnapshot.CommandMigrationMove, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat message old", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true, Hidden: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic new", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.move", + ReplacementToolID: "chat.move", + Parameters: []interfacesnapshot.CommandParameterMigration{{From: "old-id", To: "new-id"}}, + }, + }, + { + Kind: interfacesnapshot.CommandMigrationFlagExtraction, + Legacy: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat group create", + Before: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + Replacement: interfacesnapshot.CommandMigrationSide{ + Command: "dws chat topic create", + After: interfacesnapshot.CommandMigrationState{Present: true, Runnable: true}, + }, + LegacyFlag: interfacesnapshot.CommandMigrationFlag{ + Name: "thread", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", NoOpt: "true", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "bool", Hidden: true, NoOpt: "true", Scope: "local"}, + }, + Schema: interfacesnapshot.CommandMigrationSchema{ + ProductID: "chat", + SourceToolID: "chat.create_group", + ReplacementToolID: "chat.create_topic", + Parameters: []interfacesnapshot.CommandParameterMigration{ + {From: "name", To: "name"}, + {From: "type", To: "type"}, + {From: "users", To: "users"}, + { + From: "thread", + ReplacementConstant: &interfacesnapshot.CommandReplacementConstant{ + Property: "threadEnabled", + Value: true, + }, + }, + }, + }, + }, + } +} + +func schemaCommandMigrationManifest(state string) interfacesnapshot.CommandMigrationManifest { + migrations := schemaCommandMigrationAuthorizations() + for index := range migrations { + migrations[index].State = state + migrations[index].Reason = "Reviewed Schema command migration." + } + return interfacesnapshot.CommandMigrationManifest{ + Version: interfacesnapshot.CommandMigrationManifestVersion, + Migrations: migrations, + } +} + +func schemaCommandMigrationSnapshot(after bool) interfacesnapshot.Snapshot { + commands := []interfacesnapshot.Command{ + {Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + { + Path: "dws chat group create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{{ + Name: "thread", Type: "bool", NoOpt: "true", + }}, + InheritedFlags: []interfacesnapshot.Flag{}, + }, + {Path: "dws chat message old", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}}, + } + if after { + commands[1].LocalFlags[0].Hidden = true + commands[2].Hidden = true + commands = append(commands, + interfacesnapshot.Command{ + Path: "dws chat topic create", + Runnable: true, + Aliases: []string{}, + LocalFlags: []interfacesnapshot.Flag{}, + InheritedFlags: []interfacesnapshot.Flag{}, + BoolConstParams: map[string]bool{"threadEnabled": true}, + }, + interfacesnapshot.Command{Path: "dws chat topic new", 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 mutateSchemaCommandMigrationTool(contract *schemaContract, toolID string, mutate func(*toolSchema)) { + product := contract.Products["chat"] + tool := product.Tools[toolID] + mutate(&tool) + product.Tools[toolID] = tool + contract.Products["chat"] = product +} + +func mutateSchemaCommandMigrationParameter( + contract *schemaContract, + toolID string, + parameterName string, + mutate func(*parameterSchema), +) { + mutateSchemaCommandMigrationTool(contract, toolID, func(tool *toolSchema) { + parameter := tool.Parameters[parameterName] + mutate(¶meter) + tool.Parameters[parameterName] = parameter + }) +} + +func writeCommandMigrationManifestFile(t *testing.T, path string, manifest interfacesnapshot.CommandMigrationManifest) { + t.Helper() + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := json.NewEncoder(file).Encode(manifest); err != nil { + _ = file.Close() + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } +} diff --git a/scripts/policy/schema-compat/main.go b/scripts/policy/schema-compat/main.go index 1f55eae4..580eb7b6 100644 --- a/scripts/policy/schema-compat/main.go +++ b/scripts/policy/schema-compat/main.go @@ -86,6 +86,8 @@ func main() { 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) @@ -95,6 +97,9 @@ func run(args []string, stdout, stderr io.Writer) int { flags.StringVar(¤tPath, "current", "", "raw current complete Schema response") flags.StringVar(&approvedFlagMigrationsPath, "approved-flag-migrations", "", "base-owned approved flag migration manifest") 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") @@ -112,25 +117,46 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "exactly one of --normalize, --check, or --merge is required") return 2 } - migrationInputs := []string{ - approvedFlagMigrationsPath, - candidateFlagMigrationsPath, + flagMigrationPair := approvedFlagMigrationsPath != "" || candidateFlagMigrationsPath != "" + commandMigrationPair := approvedCommandMigrationsPath != "" || candidateCommandMigrationsPath != "" + if flagMigrationPair && (approvedFlagMigrationsPath == "" || candidateFlagMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema flag migration authorization requires both flag manifests") + return 2 + } + if commandMigrationPair && (approvedCommandMigrationsPath == "" || candidateCommandMigrationsPath == "") { + fmt.Fprintln(stderr, "Schema command migration authorization requires both command manifests") + return 2 + } + migrationSnapshots := []string{ migrationCurrentSnapshotPath, migrationBaseSnapshotPath, migrationStableSnapshotPath, } - migrationInputCount := 0 - for _, path := range migrationInputs { + migrationSnapshotCount := 0 + for _, path := range migrationSnapshots { if path != "" { - migrationInputCount++ + migrationSnapshotCount++ } } - if migrationInputCount != 0 && migrationInputCount != len(migrationInputs) { - fmt.Fprintln(stderr, "Schema flag migration authorization requires all five migration inputs") + migrationsEnabled := flagMigrationPair || commandMigrationPair + if migrationsEnabled && migrationSnapshotCount != len(migrationSnapshots) { + fmt.Fprintln(stderr, "Schema migration authorization requires all three interface snapshots") return 2 } - if migrationInputCount != 0 && checkPath == "" { - fmt.Fprintln(stderr, "Schema flag migration authorization is only valid with --check") + if !migrationsEnabled && migrationSnapshotCount != 0 { + fmt.Fprintln(stderr, "Schema migration snapshots require a flag or command migration manifest pair") + return 2 + } + if migrationsEnabled && checkPath == "" { + 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 } @@ -159,8 +185,9 @@ func run(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "read schema baseline: %v\n", err) return 2 } - if migrationInputCount != 0 { - migrations, err := authorizeSchemaFlagMigrations( + var flagMigrations []interfacesnapshot.FlagMigration + if flagMigrationPair { + flagMigrations, err = authorizeSchemaFlagMigrations( approvedFlagMigrationsPath, candidateFlagMigrationsPath, migrationCurrentSnapshotPath, @@ -171,7 +198,41 @@ 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 commandMigrationPair { + commandMigrations, err := authorizeSchemaCommandMigrations( + approvedCommandMigrationsPath, + candidateCommandMigrationsPath, + migrationCurrentSnapshotPath, + migrationBaseSnapshotPath, + migrationStableSnapshotPath, + ) + if err != nil { + fmt.Fprintf(stderr, "authorize Schema command migrations: %v\n", err) + return 2 + } + 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 @@ -251,6 +312,49 @@ func readFlagMigrationManifestFile(path string) (interfacesnapshot.FlagMigration return interfacesnapshot.ReadFlagMigrationManifest(bytes.NewReader(data)) } +func authorizeSchemaCommandMigrations( + approvedManifestPath string, + candidateManifestPath string, + currentSnapshotPath string, + baseSnapshotPath string, + stableSnapshotPath string, +) ([]interfacesnapshot.CommandMigration, error) { + approved, err := readCommandMigrationManifestFile(approvedManifestPath) + if err != nil { + return nil, fmt.Errorf("read approved command migrations: %w", err) + } + candidate, err := readCommandMigrationManifestFile(candidateManifestPath) + if err != nil { + return nil, fmt.Errorf("read candidate command migrations: %w", err) + } + current, err := readInterfaceSnapshotFile(currentSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration current snapshot: %w", err) + } + base, err := readInterfaceSnapshotFile(baseSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration base snapshot: %w", err) + } + stable, err := readInterfaceSnapshotFile(stableSnapshotPath) + if err != nil { + return nil, fmt.Errorf("read migration stable snapshot: %w", err) + } + return interfacesnapshot.AuthorizeCommandMigrations( + current, + map[string]interfacesnapshot.Snapshot{"merge-base": base, "stable": stable}, + approved, + candidate, + ) +} + +func readCommandMigrationManifestFile(path string) (interfacesnapshot.CommandMigrationManifest, error) { + data, err := os.ReadFile(path) + if err != nil { + return interfacesnapshot.CommandMigrationManifest{}, err + } + return interfacesnapshot.ReadCommandMigrationManifest(bytes.NewReader(data)) +} + func readInterfaceSnapshotFile(path string) (interfacesnapshot.Snapshot, error) { data, err := os.ReadFile(path) if err != nil { @@ -1157,9 +1261,9 @@ type schemaToolRef struct { } // normalizeSchemaFlagMigrations projects only an already-authorized CLI flag -// rename onto a cloned historical Schema contract. The ordinary compatibility -// checker still makes the final decision; this adapter never drops findings by -// matching their rendered text. +// rename or requiredness change onto a cloned historical Schema contract. The +// ordinary compatibility checker still makes the final decision; this adapter +// never drops findings by matching their rendered text. func normalizeSchemaFlagMigrations( baseline schemaContract, current schemaContract, @@ -1169,6 +1273,14 @@ func normalizeSchemaFlagMigrations( renamesByTool := map[schemaToolRef]map[string]string{} for _, migration := range migrations { + if migration.EffectiveKind() == interfacesnapshot.FlagMigrationRequirednessChange { + var err error + normalized, err = normalizeSchemaFlagRequirednessMigration(normalized, baseline, current, migration) + if err != nil { + return schemaContract{}, err + } + continue + } primaryPath := strings.TrimPrefix(migration.Command, "dws ") matches := schemaToolsByPrimaryPath(baseline, primaryPath) if len(matches) == 0 { @@ -1290,6 +1402,877 @@ func normalizeSchemaFlagMigrations( return normalized, nil } +func normalizeSchemaFlagRequirednessMigration( + normalized schemaContract, + baseline schemaContract, + current schemaContract, + migration interfacesnapshot.FlagMigration, +) (schemaContract, error) { + primaryPath := strings.TrimPrefix(migration.Command, "dws ") + matches := schemaToolsByPrimaryPath(baseline, primaryPath) + if len(matches) == 0 { + // A reviewed CLI-only command has no Schema compatibility surface. + return normalized, nil + } + if len(matches) != 1 { + return schemaContract{}, fmt.Errorf( + "approved flag requiredness migration %q matches %d historical Schema tools", + migration.Command, + len(matches), + ) + } + + ref := matches[0] + oldTool := baseline.Products[ref.productID].Tools[ref.toolID] + oldParameter, existed := oldTool.Parameters[migration.Flag.Name] + if !existed { + // CLI requiredness is not authority to create or mutate an unrelated + // historical Schema parameter. + return normalized, nil + } + newProduct, productExists := current.Products[ref.productID] + newTool, toolExists := newProduct.Tools[ref.toolID] + if !productExists || !toolExists || newTool.PrimaryCLIPath != primaryPath { + // Preserve the baseline so the ordinary checker reports the missing tool + // or primary path change. + return normalized, nil + } + newParameter, exists := newTool.Parameters[migration.Flag.Name] + if !exists { + // Preserve the baseline so the ordinary checker reports parameter loss. + return normalized, nil + } + if !newParameter.Required || !newParameter.CLIRequired { + return schemaContract{}, fmt.Errorf( + "approved flag requiredness migration %q Schema parameter %q must be required and cli_required", + migration.Command, + migration.Flag.Name, + ) + } + + normalizedProduct := normalized.Products[ref.productID] + normalizedTool := normalizedProduct.Tools[ref.toolID] + normalizedParameter := oldParameter + normalizedParameter.Required = newParameter.Required + normalizedParameter.CLIRequired = newParameter.CLIRequired + normalizedTool.Parameters[migration.Flag.Name] = normalizedParameter + normalizedProduct.Tools[ref.toolID] = normalizedTool + normalized.Products[ref.productID] = normalizedProduct + 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.EffectiveKind() != interfacesnapshot.FlagMigrationRename { + continue + } + 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.EffectiveKind() != interfacesnapshot.FlagMigrationRename { + continue + } + 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 +// not proven equivalent here. +func normalizeSchemaCommandMigrations( + baseline schemaContract, + current schemaContract, + migrations []interfacesnapshot.CommandMigration, +) (schemaContract, error) { + normalized := cloneContract(baseline) + for _, migration := range migrations { + oldProduct, productExists := baseline.Products[migration.Schema.ProductID] + oldTool, toolExists := oldProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !toolExists { + // The historical baseline predates this Schema tool, so it has no + // compatibility surface for this migration. + continue + } + newProduct, productExists := current.Products[migration.Schema.ProductID] + newSource, sourceExists := newProduct.Tools[migration.Schema.SourceToolID] + if !productExists || !sourceExists { + // Preserve the baseline so the ordinary checker reports the removal. + continue + } + legacyPath := strings.TrimPrefix(migration.Legacy.Command, "dws ") + replacementPath := strings.TrimPrefix(migration.Replacement.Command, "dws ") + if oldTool.PrimaryCLIPath == replacementPath { + // A consumed receipt can still be needed for the stable baseline after + // main has already reached the after state. + continue + } + if oldTool.PrimaryCLIPath != legacyPath { + return schemaContract{}, fmt.Errorf( + "approved command migration %s historical Schema tool %q has primary_cli_path %q", + migration.Kind, + migration.Schema.SourceToolID, + oldTool.PrimaryCLIPath, + ) + } + + normalizedProduct := normalized.Products[migration.Schema.ProductID] + normalizedTool := normalizedProduct.Tools[migration.Schema.SourceToolID] + switch migration.Kind { + case interfacesnapshot.CommandMigrationMove: + if newSource.PrimaryCLIPath != replacementPath { + continue + } + renames := make(map[string]string, len(migration.Schema.Parameters)) + renameTargets := make(map[string]struct{}, len(migration.Schema.Parameters)) + for _, parameter := range migration.Schema.Parameters { + oldParameter, existed := oldTool.Parameters[parameter.From] + if !existed { + return schemaContract{}, fmt.Errorf( + "approved command migration %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + if _, exists := oldTool.Parameters[parameter.To]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q Schema parameter target %q already exists in historical Schema tool %q", + migration.Legacy.Command, + parameter.To, + migration.Schema.SourceToolID, + ) + } + if _, exists := newSource.Parameters[parameter.From]; exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q still publishes legacy Schema parameter %q", + migration.Legacy.Command, + parameter.From, + ) + } + newParameter, exists := newSource.Parameters[parameter.To] + if !exists { + return schemaContract{}, fmt.Errorf( + "approved command migration %q does not publish replacement Schema parameter %q", + migration.Replacement.Command, + parameter.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, parameter, oldParameter, newParameter); err != nil { + return schemaContract{}, err + } + delete(normalizedTool.Parameters, parameter.From) + normalizedTool.Parameters[parameter.To] = newParameter + renames[parameter.From] = parameter.To + renameTargets[parameter.To] = struct{}{} + } + parameterNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + parameterNames = append(parameterNames, name) + } + sort.Strings(parameterNames) + for _, name := range parameterNames { + parameter := newSource.Parameters[name] + if _, existed := oldTool.Parameters[name]; existed { + continue + } + if _, approvedRename := renameTargets[name]; approvedRename { + continue + } + if parameter.Required || parameter.CLIRequired || parameter.RequiredWhen != "" { + return schemaContract{}, fmt.Errorf( + "approved command migration %q replacement Schema tool %q introduced unregistered required Schema parameter %q", + migration.Legacy.Command, + migration.Schema.SourceToolID, + name, + ) + } + } + 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 + + case interfacesnapshot.CommandMigrationFlagExtraction: + if newSource.PrimaryCLIPath != legacyPath { + continue + } + replacement, exists := newProduct.Tools[migration.Schema.ReplacementToolID] + if !exists || replacement.PrimaryCLIPath != replacementPath { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q lacks replacement Schema tool %q at %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + replacementPath, + ) + } + if oldTool.InterfaceMode != replacement.InterfaceMode || + oldTool.InterfaceRef != replacement.InterfaceRef || + oldTool.Availability != replacement.Availability || + oldTool.Effect != replacement.Effect || + oldTool.Risk != replacement.Risk || + oldTool.Confirmation != replacement.Confirmation || + oldTool.Idempotency != replacement.Idempotency { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed interface or safety identity", + migration.Legacy.Command, + ) + } + if oldTool.DryRun != "" && oldTool.DryRun != replacement.DryRun { + return schemaContract{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed or removed dry_run", + migration.Legacy.Command, + ) + } + var err error + normalizedTool, err = normalizeFlagExtractionSchemaMigration( + migration, + oldTool, + newSource, + replacement, + normalizedTool, + ) + if err != nil { + return schemaContract{}, err + } + } + normalizedProduct.Tools[migration.Schema.SourceToolID] = normalizedTool + normalized.Products[migration.Schema.ProductID] = normalizedProduct + } + return normalized, nil +} + +func normalizeFlagExtractionSchemaMigration( + migration interfacesnapshot.CommandMigration, + oldTool toolSchema, + newSource toolSchema, + replacement toolSchema, + normalizedSource toolSchema, +) (toolSchema, error) { + mappingsBySource := make(map[string]interfacesnapshot.CommandParameterMigration, len(migration.Schema.Parameters)) + for _, mapping := range migration.Schema.Parameters { + if _, duplicate := mappingsBySource[mapping.From]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q maps historical Schema parameter %q more than once", + migration.Legacy.Command, + mapping.From, + ) + } + if _, exists := oldTool.Parameters[mapping.From]; !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q historical Schema tool lacks parameter %q", + migration.Legacy.Command, + mapping.From, + ) + } + mappingsBySource[mapping.From] = mapping + } + + historicalNames := make([]string, 0, len(oldTool.Parameters)) + for name := range oldTool.Parameters { + historicalNames = append(historicalNames, name) + } + sort.Strings(historicalNames) + for _, name := range historicalNames { + if _, mapped := mappingsBySource[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q does not map historical Schema parameter %q", + migration.Legacy.Command, + name, + ) + } + } + + legacyName := migration.LegacyFlag.Name + legacyMapping, mapped := mappingsBySource[legacyName] + if !mapped || legacyMapping.To != "" || legacyMapping.ReplacementConstant == nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must map to one replacement constant", + migration.Legacy.Command, + legacyName, + ) + } + legacyParameter := oldTool.Parameters[legacyName] + if err := validateFlagExtractionBooleanConstant(migration, legacyParameter, legacyMapping.ReplacementConstant); err != nil { + return toolSchema{}, err + } + constantProperty := legacyMapping.ReplacementConstant.Property + if _, exists := newSource.Parameters[legacyName]; exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q still publishes extracted Schema parameter %q", + migration.Legacy.Command, + legacyName, + ) + } + sourceNames := make([]string, 0, len(newSource.Parameters)) + for name := range newSource.Parameters { + sourceNames = append(sourceNames, name) + } + sort.Strings(sourceNames) + for _, name := range sourceNames { + if newSource.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q source Schema tool still publishes replacement constant property %q through parameter %q on %q", + migration.Legacy.Command, + constantProperty, + name, + migration.Schema.SourceToolID, + ) + } + } + + replacementNames := make([]string, 0, len(replacement.Parameters)) + for name := range replacement.Parameters { + replacementNames = append(replacementNames, name) + } + sort.Strings(replacementNames) + for _, name := range replacementNames { + if replacement.Parameters[name].Property == constantProperty { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q still publishes replacement constant property %q through parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + constantProperty, + name, + ) + } + } + + replacementTargets := make(map[string]string, len(historicalNames)-1) + renames := make(map[string]string, len(historicalNames)-1) + for _, sourceName := range historicalNames { + if sourceName == legacyName { + continue + } + mapping := mappingsBySource[sourceName] + if mapping.To == "" || mapping.ReplacementConstant != nil { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q shared Schema parameter %q must map to one replacement parameter", + migration.Legacy.Command, + sourceName, + ) + } + if previous, duplicate := replacementTargets[mapping.To]; duplicate { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema parameter %q is mapped from both %q and %q", + migration.Legacy.Command, + mapping.To, + previous, + sourceName, + ) + } + replacementParameter, exists := replacement.Parameters[mapping.To] + if !exists { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q does not publish mapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + mapping.To, + ) + } + if err := validateEquivalentCommandSchemaParameter(migration, mapping, oldTool.Parameters[sourceName], replacementParameter); err != nil { + return toolSchema{}, err + } + replacementTargets[mapping.To] = sourceName + renames[sourceName] = mapping.To + } + for _, name := range replacementNames { + if _, mapped := replacementTargets[name]; !mapped { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool %q publishes unmapped Schema parameter %q", + migration.Legacy.Command, + migration.Schema.ReplacementToolID, + name, + ) + } + } + + oldConstraints, oldConstraintsOK := canonicalizeMigratedConstraints(oldTool.Constraints, renames) + replacementConstraints, replacementConstraintsOK := canonicalizeMigratedConstraints(replacement.Constraints, nil) + if !oldConstraintsOK || !replacementConstraintsOK || oldConstraints != replacementConstraints { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed constraints", + migration.Legacy.Command, + ) + } + if !equivalentMigratedPositionals(oldTool.Positionals, replacement.Positionals, renames) { + return toolSchema{}, fmt.Errorf( + "approved flag extraction %q replacement Schema tool changed positionals", + migration.Legacy.Command, + ) + } + + delete(normalizedSource.Parameters, legacyName) + return normalizedSource, nil +} + +func validateFlagExtractionBooleanConstant( + migration interfacesnapshot.CommandMigration, + parameter parameterSchema, + constant *interfacesnapshot.CommandReplacementConstant, +) error { + if strings.TrimSpace(constant.Property) == "" || parameter.Property != constant.Property { + return fmt.Errorf( + "approved flag extraction %q replacement constant property %q does not match historical Schema property %q", + migration.Legacy.Command, + constant.Property, + parameter.Property, + ) + } + if !constant.Value { + return fmt.Errorf( + "approved flag extraction %q replacement constant for Schema property %q must be constant true", + migration.Legacy.Command, + constant.Property, + ) + } + if parameter.Type != `"boolean"` { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must have boolean type", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Required || parameter.CLIRequired { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must remain optional", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.RequiredWhen != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q must not declare required_when", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Default != "" && parameter.Default != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceDefault != "" && parameter.InterfaceDefault != "false" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_default must be absent or false", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.InterfaceType != "" && parameter.InterfaceType != "boolean" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q interface_type must be empty or boolean", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if parameter.Format != "" { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q format must be empty", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + if len(parameter.Enum) > 0 { + allowsTrue := false + for _, value := range parameter.Enum { + if value == "true" { + allowsTrue = true + break + } + } + if !allowsTrue { + return fmt.Errorf( + "approved flag extraction %q legacy Schema parameter %q enum must allow true", + migration.Legacy.Command, + migration.LegacyFlag.Name, + ) + } + } + return nil +} + +func equivalentMigratedPositionals( + historical []positionalSchema, + replacement []positionalSchema, + renames map[string]string, +) bool { + if len(historical) != len(replacement) { + return false + } + for index, positional := range historical { + if renamed := renames[positional.Name]; renamed != "" { + positional.Name = renamed + } + if positional != replacement[index] { + return false + } + } + return true +} + +func validateEquivalentCommandSchemaParameter( + migration interfacesnapshot.CommandMigration, + parameter interfacesnapshot.CommandParameterMigration, + oldParameter parameterSchema, + newParameter parameterSchema, +) error { + if oldParameter.Type != newParameter.Type || + oldParameter.Property != newParameter.Property || + oldParameter.InterfaceType != newParameter.InterfaceType || + oldParameter.Required != newParameter.Required || + oldParameter.CLIRequired != newParameter.CLIRequired || + oldParameter.RequiredWhen != newParameter.RequiredWhen || + oldParameter.Default != newParameter.Default || + oldParameter.InterfaceDefault != newParameter.InterfaceDefault || + oldParameter.Format != newParameter.Format || + !equalStringSlices(oldParameter.Enum, newParameter.Enum) { + return fmt.Errorf( + "approved command migration %q Schema parameter %q -> %q changed a non-name field", + migration.Legacy.Command, + parameter.From, + parameter.To, + ) + } + return nil +} + func schemaToolsByPrimaryPath(contract schemaContract, primaryPath string) []schemaToolRef { var matches []schemaToolRef for productID, product := range contract.Products { @@ -1395,6 +2378,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 == "" { diff --git a/scripts/policy/schema-compat/main_test.go b/scripts/policy/schema-compat/main_test.go index f33f09c5..9e6c7794 100644 --- a/scripts/policy/schema-compat/main_test.go +++ b/scripts/policy/schema-compat/main_test.go @@ -1229,6 +1229,144 @@ func TestCrossPlatformCoverageSchemaFlagMigrationNormalizesExactRename(t *testin } } +func TestCrossPlatformCoverageSchemaFlagRequirednessMigration(t *testing.T) { + baseline := schemaContract{ + Version: schemaContractVersion, + Products: map[string]productSchema{ + "report": { + Tools: map[string]toolSchema{ + "report.entry_submit": { + PrimaryCLIPath: "report entry submit", + Parameters: map[string]parameterSchema{ + "to-user-ids": {Type: `"string"`, Property: "toUserIds", InterfaceType: "string"}, + }, + }, + }, + }, + }, + } + current := cloneContract(baseline) + product := current.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.Required = true + parameter.CLIRequired = true + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + current.Products["report"] = product + + migration := interfacesnapshot.FlagMigration{ + Kind: interfacesnapshot.FlagMigrationRequirednessChange, + Command: "dws report entry submit", + Flag: &interfacesnapshot.FlagMigrationSide{ + Name: "to-user-ids", + Before: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Scope: "local"}, + After: interfacesnapshot.FlagMigrationState{Present: true, Type: "string", Required: true, Scope: "local"}, + }, + State: interfacesnapshot.FlagMigrationConsumed, + Reason: "Reject report submissions that have no visible recipient.", + } + normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(normalized, current); len(failures) != 0 { + t.Fatalf("exact requiredness migration should pass after normalization: %v", failures) + } + + t.Run("non-requiredness drift remains blocked", func(t *testing.T) { + drifted := cloneContract(current) + product := drifted.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.Type = `"array"` + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + drifted.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(baseline, drifted, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, drifted), "\n"); !strings.Contains(failures, "changed type") { + t.Fatalf("requiredness migration hid type drift: %s", failures) + } + }) + + t.Run("partial Schema projection fails closed", func(t *testing.T) { + partial := cloneContract(current) + product := partial.Products["report"] + tool := product.Tools["report.entry_submit"] + parameter := tool.Parameters["to-user-ids"] + parameter.CLIRequired = false + tool.Parameters["to-user-ids"] = parameter + product.Tools["report.entry_submit"] = tool + partial.Products["report"] = product + if _, err := normalizeSchemaFlagMigrations(baseline, partial, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "required and cli_required") { + t.Fatalf("partial requiredness projection error = %v", err) + } + }) + + t.Run("CLI-only migration does not manufacture Schema", func(t *testing.T) { + cliOnly := migration + cliOnly.Command = "dws report cli-only" + normalized, err := normalizeSchemaFlagMigrations(baseline, current, []interfacesnapshot.FlagMigration{cliOnly}) + if err != nil || !reflect.DeepEqual(normalized, baseline) { + t.Fatalf("CLI-only requiredness migration = %#v, %v", normalized, err) + } + }) + + t.Run("duplicate historical path fails closed", func(t *testing.T) { + duplicate := cloneContract(baseline) + product := duplicate.Products["report"] + product.Tools["report.duplicate"] = product.Tools["report.entry_submit"] + duplicate.Products["report"] = product + if _, err := normalizeSchemaFlagMigrations(duplicate, current, []interfacesnapshot.FlagMigration{migration}); err == nil || !strings.Contains(err.Error(), "matches 2") { + t.Fatalf("duplicate requiredness Schema path error = %v", err) + } + }) + + t.Run("missing historical parameter is not authority", func(t *testing.T) { + missing := cloneContract(baseline) + product := missing.Products["report"] + tool := product.Tools["report.entry_submit"] + delete(tool.Parameters, "to-user-ids") + product.Tools["report.entry_submit"] = tool + missing.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(missing, current, []interfacesnapshot.FlagMigration{migration}) + if err != nil || !reflect.DeepEqual(normalized, missing) { + t.Fatalf("missing historical requiredness parameter = %#v, %v", normalized, err) + } + }) + + t.Run("missing current tool stays visible to ordinary checker", func(t *testing.T) { + missing := cloneContract(current) + delete(missing.Products, "report") + normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := checkCompatibility(normalized, missing); len(failures) == 0 { + t.Fatal("requiredness migration hid missing current Schema tool") + } + }) + + t.Run("missing current parameter stays visible to ordinary checker", func(t *testing.T) { + missing := cloneContract(current) + product := missing.Products["report"] + tool := product.Tools["report.entry_submit"] + delete(tool.Parameters, "to-user-ids") + product.Tools["report.entry_submit"] = tool + missing.Products["report"] = product + normalized, err := normalizeSchemaFlagMigrations(baseline, missing, []interfacesnapshot.FlagMigration{migration}) + if err != nil { + t.Fatal(err) + } + if failures := strings.Join(checkCompatibility(normalized, missing), "\n"); !strings.Contains(failures, "lost parameter") { + t.Fatalf("requiredness migration hid missing current parameter: %s", failures) + } + }) +} + func TestCrossPlatformCoverageSchemaFlagMigrationRejectsSemanticDrift(t *testing.T) { tests := []struct { name string @@ -1589,7 +1727,7 @@ func TestCrossPlatformCoverageSchemaMigrationCLIRequiresCompleteCheckInputs(t *t "--migration-stable-snapshot", "stable.json", } var stdout, stderr bytes.Buffer - if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "all five") { + if code := run([]string{"--check", "baseline.json", "--current", "schema.json", "--approved-flag-migrations", "approved.json"}, &stdout, &stderr); code != 2 || !strings.Contains(stderr.String(), "both flag manifests") { t.Fatalf("partial migration inputs code=%d stderr=%q", code, stderr.String()) } @@ -1739,11 +1877,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, diff --git a/scripts/policy/schemacompat/authority_integration_test.go b/scripts/policy/schemacompat/authority_integration_test.go index 5866ee50..9346e1c1 100644 --- a/scripts/policy/schemacompat/authority_integration_test.go +++ b/scripts/policy/schemacompat/authority_integration_test.go @@ -32,6 +32,8 @@ 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") flag.Parse() if *normalize != "" { @@ -171,6 +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") + 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 != "" { @@ -210,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) @@ -282,6 +310,27 @@ var ( func AuthorityMarker() string { return "BASE" } ` +const schemaCommandGovernedSnapshotSource = `package interfacesnapshot + +import ( + "example.com/dws-schema-compat-authority-test/internal/corecmd" + "example.com/dws-schema-compat-authority-test/internal/corecmd/runtimeannotate" +) + +var ( + _ = runtimeannotate.AnnotationFlagAliasOf + _ = runtimeannotate.AnnotationFlagAliasOrigin + _ = runtimeannotate.FlagAliasOriginCorecmdV1 +) + +func AuthorityMarker() string { + if corecmd.InterfaceBoolConstParams() != "BASE" { + return "UNTRUSTED_CONST_PARAMS" + } + return "BASE" +} +` + const schemaBaseSnapshotSource = `package interfacesnapshot func AuthorityMarker() string { return "BASE" } @@ -306,6 +355,54 @@ const ( ) ` +func schemaConstParamsRegistrySource(marker string) string { + return fmt.Sprintf(`package corecmd + +type constParamsRegistry struct{} + +func (constParamsRegistry) Store(any, any) {} + +var interfaceBoolConstParamsRegistry constParamsRegistry + +func attachInterfaceBoolConstParams() {} + +func InterfaceBoolConstParams() string { return %q } +`, marker) +} + +const schemaBaseCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": true} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaForgedCorecmdBridgeSource = `package corecmd + +func boolConstParams(map[string]any) map[string]bool { + return map[string]bool{"convThreadEnabled": false} +} + +func installConstParamsEvidence() { attachInterfaceBoolConstParams() } +` + +const schemaBaseLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + return toolArgs +} +` + +const schemaForgedLeafAdapterSource = `package helpers + +func forwardToolArgs(toolArgs map[string]any) map[string]any { + delete(toolArgs, "convThreadEnabled") + return toolArgs +} +` + const candidateNoopSource = `package main import "fmt" @@ -327,12 +424,14 @@ func main() { } type authorityScenario struct { - name string - baseGovernance string - candidateMutation string - checkerMode string - want string - authorityMarker string + name string + baseGovernance string + commandGovernance string + commandManifestChanged bool + candidateMutation string + checkerMode string + want string + authorityMarker string } func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testing.T) { @@ -343,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", }, { @@ -353,6 +459,85 @@ func TestCrossPlatformCoverageSchemaCompatibilityUsesBaseOwnedAuthority(t *testi baseGovernance: "none", authorityMarker: "OLD_BASE_STABLE_SCHEMA_CHECKER_ENFORCED", }, + { + name: "command governed base owns bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "change-const-params-protocol", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governance bootstrap preserves candidate bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "bootstrap", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "command governed candidate cannot delete bool ConstParams contract", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "delete-const-params-contract", + want: "must preserve the complete Schema command migration governance artifact set", + }, + { + name: "same package candidate cannot call private ConstParams writer", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-token", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "candidate cannot reopen private ConstParams reader", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-reader", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "same package candidate cannot store directly in ConstParams registry", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-const-params-registry-store", + want: "may not access framework-owned bool ConstParams registry", + }, + { + name: "changed command manifest rejects forged corecmd bool ConstParams bridge", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-corecmd-bool-const-params", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/corecmd.go", + }, + { + name: "changed command manifest rejects tampered ConstParams protocol", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "change-const-params-protocol", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/corecmd/interface_const_params.go", + }, + { + name: "changed command manifest rejects tampered leaf adapter", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + candidateMutation: "forge-leaf-adapter", + want: "candidate command migration manifest differs from base; protected bridge must preserve the base Git blob: internal/helpers/leaf.go", + }, + { + name: "changed command manifest allows base identical bridges", + baseGovernance: "complete", + commandGovernance: "complete", + commandManifestChanged: true, + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, + { + name: "unchanged command manifest allows independent corecmd change", + baseGovernance: "complete", + commandGovernance: "complete", + candidateMutation: "forge-corecmd-bool-const-params", + authorityMarker: "BASE_SCHEMA_CHECKER_ENFORCED", + }, { name: "partial base fails closed", baseGovernance: "partial", @@ -457,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) @@ -479,6 +664,19 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { default: t.Fatalf("unknown base governance state %q", test.baseGovernance) } + switch test.commandGovernance { + case "": + case "complete": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "snapshot.go"), schemaCommandGovernedSnapshotSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaBaseCorecmdBridgeSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("BASE"), 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaBaseLeafAdapterSource, 0o644) + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), "{\"version\":1,\"migrations\":[]}\n", 0o644) + case "bootstrap": + default: + t.Fatalf("unknown command governance state %q", test.commandGovernance) + } schemaRun(t, fixtureRoot, "git", "add", ".") schemaRun(t, fixtureRoot, "git", "commit", "-m", "base authority") baseRef := strings.TrimSpace(schemaRun(t, fixtureRoot, "git", "rev-parse", "HEAD")) @@ -508,6 +706,17 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { candidateLedger = "{\"version\":1,\"migrations\":[{\"candidate\":\"COMMITTED\"}]}\n" } schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-flag-migrations-v1.json"), candidateLedger, 0o644) + if test.commandGovernance == "complete" || test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "interfacesnapshot", "command_migrations.go"), "package interfacesnapshot\n", 0o644) + if test.commandGovernance == "bootstrap" { + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + } + commandManifest := "{\"version\":1,\"migrations\":[]}\n" + if test.commandManifestChanged { + commandManifest = "{\"version\":1,\"migrations\":[{\"candidate\":\"PENDING\"}]}\n" + } + schemaWriteFile(t, filepath.Join(fixtureRoot, "scripts", "policy", "interface-migrations", "approved-command-migrations-v1.json"), commandManifest, 0o644) + } schemaWriteFile(t, filepath.Join(fixtureRoot, "SNAPSHOT_MARKER"), "COMMITTED_CANDIDATE\n", 0o644) switch test.candidateMutation { case "": @@ -534,6 +743,34 @@ func runSchemaAuthorityCase(t *testing.T, test authorityScenario) { "package evil\n\nconst forged = \"dws.compat.alias_of\"\n", 0o644, ) + case "delete-const-params-contract": + if err := os.Remove(filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go")); err != nil { + t.Fatalf("remove candidate bool ConstParams registry: %v", err) + } + case "forge-const-params-token": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParams() { attachInterfaceBoolConstParams() }\n", + 0o644, + ) + case "forge-const-params-reader": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "evil", "forged.go"), + "package evil\n\nconst forged = \"InterfaceBoolConstParams\"\n", + 0o644, + ) + case "forge-const-params-registry-store": + schemaWriteFile(t, + filepath.Join(fixtureRoot, "internal", "corecmd", "evil_const_params.go"), + "package corecmd\n\nfunc forgeConstParamsStore() { interfaceBoolConstParamsRegistry.Store(nil, map[string]bool{\"forged\": true}) }\n", + 0o644, + ) + case "forge-corecmd-bool-const-params": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "corecmd.go"), schemaForgedCorecmdBridgeSource, 0o644) + case "change-const-params-protocol": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "corecmd", "interface_const_params.go"), schemaConstParamsRegistrySource("CANDIDATE"), 0o644) + case "forge-leaf-adapter": + schemaWriteFile(t, filepath.Join(fixtureRoot, "internal", "helpers", "leaf.go"), schemaForgedLeafAdapterSource, 0o644) case "symlink-helper-parent": helperPath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot") alternatePath := filepath.Join(fixtureRoot, "internal", "interfacesnapshot-alt") @@ -588,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", diff --git a/scripts/release/check-release-compatibility.sh b/scripts/release/check-release-compatibility.sh new file mode 100755 index 00000000..290826bb --- /dev/null +++ b/scripts/release/check-release-compatibility.sh @@ -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 --base-ref --stable-ref [--candidate-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 diff --git a/scripts/release/prepare-changelog.sh b/scripts/release/prepare-changelog.sh index 053a46b7..e7a7b25a 100755 --- a/scripts/release/prepare-changelog.sh +++ b/scripts/release/prepare-changelog.sh @@ -64,17 +64,49 @@ release_date="${DWS_RELEASE_DATE:-$(TZ=Asia/Shanghai date +%F)}" section="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-section.XXXXXX")" output="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-output.XXXXXX")" fragments="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragments.XXXXXX")" -cleanup() { rm -f "$section" "$output" "$fragments"; } +fragment_entries="$(mktemp "${TMPDIR:-/tmp}/dws-changelog-fragment-entries.XXXXXX")" +cleanup() { rm -f "$section" "$output" "$fragments" "$fragment_entries"; } trap cleanup EXIT HUP INT TERM +render_fragments=false +if [ "$CHANNEL" = "prerelease" ]; then + render_fragments=true +else + if [ -e "$CHANGES_DIR" ] && [ ! -d "$CHANGES_DIR" ]; then + printf 'release fragments path is not a directory: %s\n' "$CHANGES_DIR" >&2 + exit 1 + fi + if [ -d "$CHANGES_DIR" ]; then + find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -print | sort >"$fragment_entries" + while IFS= read -r entry; do + base="${entry##*/}" + if [ "$base" = 'README.md' ] && [ -f "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + if [ "$base" = 'released' ] && [ -d "$entry" ] && [ ! -L "$entry" ]; then + continue + fi + render_fragments=true + break + done <"$fragment_entries" + fi +fi + +if [ "$render_fragments" = true ]; then + "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" +fi + { printf '## [%s] - %s\n\n' "$semver" "$release_date" if [ "$CHANNEL" = "stable" ]; then printf 'This release promotes the sealed `%s` contents to stable.\n\n' "$FROM_BETA" printf '### Changed\n\n' printf -- '- TODO: summarize the complete user-visible release promoted from `%s`.\n' "$FROM_BETA" + if [ -s "$fragments" ]; then + printf '\n### Changes since `%s`\n\n' "$FROM_BETA" + cat "$fragments" + fi else - "$SCRIPT_DIR/render-release-fragments.sh" "$CHANGES_DIR" >"$fragments" cat "$fragments" fi } > "$section" @@ -106,14 +138,18 @@ fi [ "$inserted" -eq 1 ] || { printf 'CHANGELOG is missing ## [Unreleased]\n' >&2; exit 1; } cp "$output" "$CHANGELOG" -if [ "$CHANNEL" = "prerelease" ]; then +if [ -s "$fragments" ]; then archive_dir="$CHANGES_DIR/released/$semver" mkdir -p "$archive_dir" find "$CHANGES_DIR" -mindepth 1 -maxdepth 1 -type f -name '*.md' ! -name 'README.md' -exec mv {} "$archive_dir"/ \; fi if [ "$CHANNEL" = "stable" ]; then - printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + if [ -s "$fragments" ]; then + printf 'Prepared CHANGELOG template and archived post-beta release fragments for %s. Replace TODO, review, commit, and merge the release-seal PR before release.\n' "$VERSION" + else + printf 'Prepared CHANGELOG template for %s. Replace TODO, review, commit, and merge it before release.\n' "$VERSION" + fi else printf 'Prepared CHANGELOG and archived release fragments for %s. Review, commit, and merge the release-seal PR before release.\n' "$VERSION" fi diff --git a/scripts/release/release.sh b/scripts/release/release.sh index bb7dea1f..eb44b5a5 100755 --- a/scripts/release/release.sh +++ b/scripts/release/release.sh @@ -19,7 +19,7 @@ usage() { cat >&2 <<'EOF' usage: release.sh [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 diff --git a/scripts/release/verify-package-managers.sh b/scripts/release/verify-package-managers.sh index 71b0d8c0..da1512b7 100755 --- a/scripts/release/verify-package-managers.sh +++ b/scripts/release/verify-package-managers.sh @@ -54,42 +54,92 @@ while [ "$#" -gt 0 ]; do done TMP_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/dws-package-verify-XXXXXX")" -HOME_SPECIFIC_AGENT_PARENTS=" -.claude -.cursor -.qoder -.qoderwork -.gemini -.codex -.zcode -.github -.windsurf -.augment -.cline -.amp -.kiro -.trae -.openclaw -.hermes -" -HOME_SPECIFIC_SKILL_BASES=" -.claude/skills -.cursor/skills -.qoder/skills -.qoderwork/skills -.gemini/skills -.codex/skills -.zcode/skills -.github/skills -.windsurf/skills -.augment/skills -.cline/skills -.amp/skills -.kiro/skills -.trae/skills -.openclaw/skills -.hermes/skills -" +# vercel-labs/skills agents.ts (c6f69c6), exactly 76 upstream IDs. +# Format: id|universal(1/0)|effective default global root; '-' has no global root. +UPSTREAM_AGENT_REGISTRY='aider-desk|0|.aider-desk/skills +amp|1|.config/agents/skills +antigravity|1|.gemini/antigravity/skills +antigravity-cli|1|.gemini/antigravity-cli/skills +astrbot|0|.astrbot/data/skills +autohand-code|0|.autohand/skills +augment|0|.augment/skills +bob|0|.bob/skills +claude-code|0|.claude/skills +openclaw|0|.openclaw/skills +cline|1|.agents/skills +codearts-agent|0|.codeartsdoer/skills +codebuddy|0|.codebuddy/skills +codemaker|0|.codemaker/skills +codestudio|0|.codestudio/skills +codex|1|.codex/skills +command-code|0|.commandcode/skills +continue|0|.continue/skills +cortex|0|.snowflake/cortex/skills +crush|0|.config/crush/skills +cursor|1|.cursor/skills +deepagents|1|.deepagents/agent/skills +devin|0|.config/devin/skills +dexto|1|.agents/skills +droid|0|.factory/skills +eve|0|- +firebender|1|.firebender/skills +forgecode|0|.forge/skills +gemini-cli|1|.gemini/skills +github-copilot|1|.copilot/skills +goose|0|.config/goose/skills +grok|0|.grok/skills +hermes-agent|0|.hermes/skills +inference-sh|0|.inferencesh/skills +jazz|0|.jazz/skills +junie|0|.junie/skills +iflow-cli|0|.iflow/skills +kilo|0|.kilocode/skills +kimchi|0|.config/kimchi/harness/skills +kimi-code-cli|1|.agents/skills +kiro-cli|0|.kiro/skills +kode|0|.kode/skills +lingma|0|.lingma/skills +loaf|1|.agents/skills +mcpjam|0|.mcpjam/skills +minimax-code|0|.minimax/skills +mistral-vibe|0|.vibe/skills +moxby|0|.moxby/skills +mux|0|.mux/skills +opencode|1|.config/opencode/skills +openhands|0|.openhands/skills +ona|0|.ona/skills +pi|0|.pi/agent/skills +qoder|0|.qoder/skills +qoder-cn|0|.qoder-cn/skills +qwen-code|0|.qwen/skills +replit|1|.config/agents/skills +reasonix|0|.reasonix/skills +rovodev|0|.rovodev/skills +roo|0|.roo/skills +tabnine-cli|0|.tabnine/agent/skills +terramind|0|.terramind/skills +tinycloud|0|.tinycloud/skills +trae|0|.trae/skills +trae-cn|0|.trae-cn/skills +warp|1|.agents/skills +windsurf|0|.codeium/windsurf/skills +zed|1|.agents/skills +zcode|0|.zcode/skills +zencoder|0|.zencoder/skills +zenflow|0|.zencoder/skills +neovate|0|.neovate/skills +pochi|0|.pochi/skills +promptscript|1|- +adal|0|.adal/skills +universal|1|.config/agents/skills' + +# DWS-only compatibility and old wrong global paths. Qoderwork remains a +# non-universal install target; the other entries verify migration cleanup. +LEGACY_AGENT_CLEANUP_REGISTRY='dws-qoderwork|0|.qoderwork/skills +dws-legacy-github|1|.github/skills +dws-legacy-amp|1|.amp/skills +dws-legacy-cline|1|.cline/skills +dws-legacy-windsurf|1|.windsurf/skills' cleanup() { if [ "$RUN_BREW" -eq 1 ] && command -v brew >/dev/null 2>&1 && [ -n "${BREW_TAP_NAME:-}" ]; then HOME="$TMP_ROOT/brew-home" HOMEBREW_NO_AUTO_UPDATE=1 HOMEBREW_NO_INSTALL_CLEANUP=1 \ @@ -103,11 +153,61 @@ trap cleanup EXIT INT TERM seed_specific_agent_homes() { home_root="$1" - for parent in $HOME_SPECIFIC_AGENT_PARENTS; do - mkdir -p "$home_root/$parent" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + mkdir -p "${base%/skills}" done } +resolve_agent_base() { + home_root="$1"; id="$2"; default_path="$3" + case "$id" in + autohand-code) printf '%s\n' "${AUTOHAND_HOME:-$home_root/.autohand}/skills" ;; + claude-code) printf '%s\n' "${CLAUDE_CONFIG_DIR:-$home_root/.claude}/skills" ;; + codex) printf '%s\n' "${CODEX_HOME:-$home_root/.codex}/skills" ;; + grok) printf '%s\n' "${GROK_HOME:-$home_root/.grok}/skills" ;; + hermes-agent) printf '%s\n' "${HERMES_HOME:-$home_root/.hermes}/skills" ;; + mistral-vibe) printf '%s\n' "${VIBE_HOME:-$home_root/.vibe}/skills" ;; + amp|replit|universal) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/agents/skills" ;; + crush|devin|goose|kimchi|opencode) printf '%s\n' "${XDG_CONFIG_HOME:-$home_root/.config}/${default_path#.config/}" ;; + openclaw) + for alias in .openclaw .clawdbot .moltbot; do + if [ -d "$home_root/$alias" ]; then printf '%s\n' "$home_root/$alias/skills"; return; fi + done + printf '%s\n' "$home_root/.openclaw/skills" + ;; + *) printf '%s\n' "$home_root/$default_path" ;; + esac +} + +# Package verification must never inherit the runner's real Agent roots. In +# particular, hosted Linux runners can define XDG_CONFIG_HOME globally; two +# parallel verification jobs would then publish links into the same directory +# and make one another's links appear broken. Keep every supported override +# inside the scenario-specific temporary HOME instead. +with_isolated_agent_env() ( + isolated_home=$1 + shift + HOME="$isolated_home" + AUTOHAND_HOME="$isolated_home/.autohand" + CLAUDE_CONFIG_DIR="$isolated_home/.claude" + CODEX_HOME="$isolated_home/.codex" + GROK_HOME="$isolated_home/.grok" + HERMES_HOME="$isolated_home/.hermes" + VIBE_HOME="$isolated_home/.vibe" + XDG_CONFIG_HOME="$isolated_home/.config" + export HOME AUTOHAND_HOME CLAUDE_CONFIG_DIR CODEX_HOME GROK_HOME HERMES_HOME VIBE_HOME XDG_CONFIG_HOME + "$@" +) + verify_skill_base() { home_root="$1" base="$2" @@ -116,25 +216,71 @@ verify_skill_base() { [ ! -e "$home_root/$base/dws" ] || err "unexpected mono Skill layout found in $home_root/$base/dws" } -verify_skill_targets() { - home_root="$1" - found_specific=0 - for base in $HOME_SPECIFIC_SKILL_BASES; do - parent="${base%/skills}" - if [ -d "$home_root/$parent" ]; then - verify_skill_base "$home_root" "$base" - found_specific=1 +verify_compatible_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + target="$base/$name" + if [ -L "$target" ]; then + link_target="$(readlink "$target")" || err "could not read canonical Skill link at $target" + # Relative link targets are the headline invariant of the canonical store: + # an absolute target breaks as soon as the home directory moves or the + # store is relocated, so reject it even though it resolves today. + case "$link_target" in + /*) err "canonical Skill link must be relative at $target (target=$link_target)" ;; + esac + linked_real="$(CDPATH= cd -- "$target" 2>/dev/null && pwd -P)" || err "broken canonical Skill link at $target" + canonical_real="$(CDPATH= cd -- "$canonical/$name" && pwd -P)" || err "canonical Skill missing at $canonical/$name" + [ "$linked_real" = "$canonical_real" ] || err "Skill link does not resolve to canonical source at $target" + elif [ -d "$target" ]; then + diff -qr "$canonical/$name" "$target" >/dev/null || err "Skill copy fallback differs from canonical source at $target" + else + err "canonical Skill compatibility target missing at $target" fi done + [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ] || err "unexpected mono Skill layout found in $base/dws" +} - if [ "$found_specific" -eq 0 ]; then - verify_skill_base "$home_root" ".agents/skills" - return - fi +# Universal Agents read the canonical store directly, so they must own no copy +# of ANY canonical Skill. Iterate the real store instead of a hardcoded name +# list: a hardcoded list silently passed leftover copies of every other +# dingtalk-* Skill from earlier betas. +verify_universal_skill_base() { + home_root="$1"; base="$2"; canonical="$home_root/.agents/skills" + for skill_md in "$canonical"/*/SKILL.md; do + [ -f "$skill_md" ] || continue + name=$(basename "$(dirname "$skill_md")") + { [ ! -e "$base/$name" ] && [ ! -L "$base/$name" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/$name" + done + # The mono layout is absent from a multi canonical store, so gate it directly. + { [ ! -e "$base/dws" ] && [ ! -L "$base/dws" ]; } || \ + err "unexpected duplicate Skill found in universal Agent root $base/dws" +} - for name in dingtalk-shared dingtalk-misc dws; do - [ ! -e "$home_root/.agents/skills/$name" ] || \ - err "unexpected generic Skill copy found in $home_root/.agents/skills/$name" +verify_skill_targets() { + home_root="$1" + # The universal-convention canonical store is always present, regardless of + # which concrete Agent homes existed at install time. + verify_skill_base "$home_root" ".agents/skills" + seen='' + for row in $UPSTREAM_AGENT_REGISTRY $LEGACY_AGENT_CLEANUP_REGISTRY; do + id=${row%%|*}; rest=${row#*|}; universal=${rest%%|*}; path=${rest#*|} + [ "$path" != '-' ] || continue + base=$(resolve_agent_base "$home_root" "$id" "$path") + [ -n "$base" ] || continue + key=$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]') + printf '%s\n' "$seen" | grep -Fqx "$key" && continue + seen="${seen}${key} +" + [ "$base" != "$home_root/.agents/skills" ] || continue + [ -d "${base%/skills}" ] || continue + if [ "$universal" -eq 1 ]; then + verify_universal_skill_base "$home_root" "$base" + continue + fi + verify_compatible_skill_base "$home_root" "$base" done } @@ -146,15 +292,15 @@ verify_npm_install() { npm_cache="$TMP_ROOT/npm-cache-$scenario" mkdir -p "$npm_home" "$npm_prefix" "$npm_cache" if [ "$scenario" = "specific-agent-roots" ]; then - seed_specific_agent_homes "$npm_home" + with_isolated_agent_env "$npm_home" seed_specific_agent_homes "$npm_home" fi say "==> verifying npm package install ($scenario)" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm install -g "$tarball_path" >/dev/null [ -x "$npm_prefix/bin/dws" ] || err "npm install did not expose dws in $npm_prefix/bin" - HOME="$npm_home" "$npm_prefix/bin/dws" --help >/dev/null + with_isolated_agent_env "$npm_home" "$npm_prefix/bin/dws" --help >/dev/null if [ -n "$EXPECTED_VERSION" ]; then vendor_bin="$npm_prefix/lib/node_modules/dingtalk-workspace-cli/vendor/dws" need_file "$vendor_bin" @@ -165,9 +311,9 @@ verify_npm_install() { if (pkg.version !== process.env.EXPECTED_VERSION) process.exit(1); ' "$NPM_STAGE_DIR/package.json" || err "npm package.json version mismatch" fi - [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || verify_skill_targets "$npm_home" + [ "$VERIFY_SKILL_TARGETS" -eq 0 ] || with_isolated_agent_env "$npm_home" verify_skill_targets "$npm_home" - HOME="$npm_home" npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ + with_isolated_agent_env "$npm_home" env npm_config_cache="$npm_cache" npm_config_prefix="$npm_prefix" \ npm uninstall -g dingtalk-workspace-cli >/dev/null } @@ -176,6 +322,7 @@ verify_npm() { need_cmd node need_cmd tar need_cmd unzip + need_cmd diff need_file "$NPM_STAGE_DIR/package.json" tarball_name="$( diff --git a/skills/content-qa/mono-multi-coverage.yaml b/skills/content-qa/mono-multi-coverage.yaml index 440c149e..8dc8895a 100644 --- a/skills/content-qa/mono-multi-coverage.yaml +++ b/skills/content-qa/mono-multi-coverage.yaml @@ -108,6 +108,10 @@ coverage: multi_skill: dingtalk-misc multi_refs: - references/whiteboard.md + - mono: recruit + multi_skill: dingtalk-misc + multi_refs: + - references/recruit.md omit_coverage: - mono: simple diff --git a/skills/mono/SKILL.md b/skills/mono/SKILL.md index 189ffbad..3d226752 100644 --- a/skills/mono/SKILL.md +++ b/skills/mono/SKILL.md @@ -1,6 +1,6 @@ --- name: dws -description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/终止/完成,以及审批任务创建/完成/转交时使用。 +description: 管理钉钉产品能力(AI表格/AI搜问/日历/通讯录/群聊与机器人/待办/审批/考勤/日志/DING消息/开放平台文档/钉钉文档/钉钉云盘/原生Markdown文件/AI听记/邮箱/在线电子表格/知识库等)。当用户需要操作表格数据、管理日程会议、模糊找人/查谁负责某事项、查询通讯录、管理群聊、机器人发消息、创建待办、提交审批、查看考勤、提交日报周报(钉钉日志模版)、读写钉钉文档、上传下载云盘文件、读取或修改原生.md文件、查询听记纪要、收发邮件、读写在线电子表格(axls)、管理钉钉知识库,或订阅个人 IM 事件或 OA 审批事件、实时监听群成员加入、群成员退出、群改名和群解散、审批实例发起/抄送/终止/完成,以及审批任务创建/完成/转交时使用。 cli_version: ">=1.0.15" --- @@ -44,18 +44,18 @@ cli_version: ">=1.0.15" | 服务 | shortcut 数 | multi skill | |---|---:|---| | `aitable` | 93 | `dingtalk-aitable` | -| `attendance` | 19 | `dingtalk-misc` | +| `attendance` | 8 | `dingtalk-misc` | | `calendar` | 27 | `dingtalk-calendar` | | `chat` | 98 | `dingtalk-chat` | | `contact` | 14 | `dingtalk-contact` | | `devapp` | 19 | `dingtalk-misc` | -| `ding` | 4 | `dingtalk-misc` | +| `ding` | 1 | `dingtalk-misc` | | `doc` | 45 | `dingtalk-doc` | | `drive` | 28 | `dingtalk-drive` | -| `mail` | 10 | `dingtalk-mail` | +| `mail` | 8 | `dingtalk-mail` | | `minutes` | 27 | `dingtalk-minutes` | -| `oa` | 7 | `dingtalk-misc` | -| `report` | 2 | `dingtalk-misc` | +| `oa` | 1 | `dingtalk-misc` | +| `report` | 4 | `dingtalk-misc` | | `sheet` | 2 | `dingtalk-misc` | | `todo` | 21 | `dingtalk-todo` | | `wiki` | 20 | `dingtalk-wiki` | @@ -94,6 +94,7 @@ cli_version: ">=1.0.15" | `todo` | 待办:创建(含优先级/截止时间/循环)/查询/修改/标记完成/删除 | [todo.md](./references/products/todo.md) | | `wiki` | 知识库:空间创建/详情/列表/搜索 + 成员管理 + 知识库动态查询 | [wiki.md](./references/products/wiki.md) | | `whiteboard` | 文档内嵌白板:读取 OpenNodes、追加节点、整页重建 | [whiteboard.md](./references/products/whiteboard.md) | +| `recruit` | 钉钉招聘:查询职位列表、获取职位详情、创建职位 | [recruit.md](./references/products/recruit.md) | | `event` | 个人 IM/OA 事件:监听消息、群生命周期、审批任务与审批实例事件,NDJSON 输出(实时驱动 Agent)| [event.md](./references/products/event.md) | ## 意图判断决策树 @@ -121,6 +122,7 @@ cli_version: ">=1.0.15" 用户提到"待办/TODO/任务提醒/循环待办" → `todo` 用户提到"创建知识库/知识库列表/搜索知识库空间/wiki/团队空间/知识库成员管理/我的文档个人空间" → `wiki` 用户提到"文档内嵌白板/画布/OpenNodes/白板节点/连接线/整页重建白板" → `whiteboard`;创建空白板卡片先走 `doc whiteboard insert` +用户提到"招聘/职位/JD/在招职位/创建职位/职位详情" → `recruit` 用户提到"监听有人@我/监听单聊或群消息/监听所有单聊或群消息/监听某人发送的消息/监听消息已读/监听消息撤回/监听消息贴表情或表情回应/订阅个人 IM 事件/实时接收钉钉事件/监听并自动回复消息/驱动 Agent 处理消息" → `event +listen-im`;群成员加入/退出、群改名/解散或明确原始 EventKey/Filter DSL → `event consume` 用户提到"监听待我审批的任务/监听审批任务创建、完成或转交/监听审批单发起或终止/监听我发起的审批完成/监听审批实例完成/订阅 OA 事件/event consume user_oa_approval_*" → `event consume` diff --git a/skills/mono/references/products/chat.md b/skills/mono/references/products/chat.md index 5fbff337..90159f7b 100644 --- a/skills/mono/references/products/chat.md +++ b/skills/mono/references/products/chat.md @@ -437,23 +437,23 @@ Flags: --role-id string 群身份 openRoleId,由 group-role list 返回 (必填) ``` -#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份(传空则清除所有身份) +#### 设置用户群身份 — 覆盖指定用户在群中的全部群身份 ``` Usage: dws chat group-role set-user [flags] Example: - dws chat group-role set-user --group --user --role-ids roleId1,roleId2 + dws chat group-role set-user --group --user --role-id # 查询人员: dws aisearch person --query "姓名" --dimension name # 查询 role-id: dws chat group-role list --group Flags: - --group string 群聊 openConversationId (必填) - --user string 用户 userId(必填) - --role-ids string 群身份 openRoleId 列表,逗号分隔 (必填),传空字符串则清除该用户所有群身份 + --group string 群聊 openConversationId (必填) + --user string 用户 userId(必填) + --role-id string 群身份 openRoleId,由 group-role list 返回 ``` #### 移除用户的指定群身份 — 从用户身上移除指定的群身份(不影响其他群身份) -> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。需要清除某人身份时,用 `chat group-role set-user --role-ids ""`(传空 role-ids 覆盖为无身份)兜底;但 set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 +> ⚠️ 当前不可用:该命令当前调用常失败(服务端返回 1002 系统繁忙),多为服务端侧限制。set-user 会清掉该用户的**全部**身份,无法只移除其中一个。 ``` Usage: dws chat group-role remove-user [flags] @@ -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 --group + dws chat emotion send --media-id --emotion-id --user + dws chat emotion send --media-id --open-dingtalk-id --uuid +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 --name "赞" + dws chat emotion favorite --media-id --source-conversation-id --source-message-id +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` — 创建智能会话分组(可指定群名称关键词和成员作为匹配规则) @@ -2457,7 +2506,7 @@ Flags: | `chat conversation-info` | `newCSpaceIdIM` | 独立钉盘存储流程的共享空间 ID;不是发送本地聊天附件的前置条件 | | `chat file upload` | 无(已下线) | 不要调用;常规发图/发文件用 `chat message send --msg-type file --file` | | `chat message list` | `openMsgId` | message read-status 的 --message-id | -| `chat group-role list` | `openRoleId` | group-role update/remove/set-user/remove-user 的 --role-id | +| `chat group-role list` | `openRoleId` | group-role update/remove/set-user 的 --role-id;remove-user 的 --role-ids | | `chat message create-text-emotion` | `emotionId` | add-text-emotion 的 --emotion-id | | `chat category list` | `categoryId` | category list-conversations 的 --category-id | | `chat group get-by-group-id` | `openConversationId` | 同 chat search,将群号转为 openConversationId | @@ -2494,7 +2543,7 @@ Flags: - `send-by-bot` 群聊传 `--conversation-id`,单聊传 `--users` 或 `--open-dingtalk-ids`,与群目标互斥且必选其一;机器人群聊 Markdown 引用回复必须同时传 `--reply ` 与 `--ref-sender `,单聊、图片和文件不支持引用回复;群聊时可选 `--at-user-ids` @指定成员(传 userId 列表)或 `--at-open-dingtalk-ids` @指定成员(传 openDingTalkId 列表),content 中需包含对应 @标识;`--at-all` @所有人;群聊场景如果返回"机器人不存在"错误,需先通过 `chat group members add-bot --conversation-id --robot-code ` 将机器人邀请进群后再发送 - `recall-by-bot` 群聊传 `--group` + `--keys`,单聊仅传 `--keys`(不传 `--group` 即为单聊撤回) - `send-by-webhook` 支持 `--at-all`、`--at-mobiles`、`--at-users` 进行 @ 操作,但需在 `--content` 中包含 `@userId` 或 `@手机号` 才能生效;`--at-all` @所有人时需在 `--content` 中包含 `@10` -- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 覆盖某人全部身份(传空 --role-ids 则清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` +- `chat group-role` 系列命令用于管理群的自定义身份标签:`list` 查列表,`add` 创建,`update` 改名,`remove` 删除;`set-user` 用单个 `--role-id` 覆盖某人全部身份(不支持传空值清除),`remove-user` 仅移除指定身份,`query-user` 查询某人当前身份;用户用 `--user ` - 消息**换行符**(`send` / `send-by-webhook` 使用 `--content`,`send-by-bot` 使用 `--text`)有两层要求:(1) 必须是**真实换行符** `U+000A`,不是字面量 `\n`;(2) Markdown 规范下单换行不生效,需用空行 `\n\n`(段落分隔)或行尾两空格 + 换行 / `
`(硬换行) - `chat group transfer-owner` 转让群主,需传 --group(openConversationId);新群主 userId 用 `--user`,openDingTalkId 用 `--new-owner` - `chat group invite-url` 获取群邀请链接,需传 --group(openConversationId),可选 --expires-seconds 指定有效期(秒,0=永久) diff --git a/skills/mono/references/products/event.md b/skills/mono/references/products/event.md index f9b82c95..15178398 100644 --- a/skills/mono/references/products/event.md +++ b/skills/mono/references/products/event.md @@ -47,10 +47,11 @@ | `user_oa_approval_task_finished` | 审批任务已完成 | 无 | | `user_oa_approval_task_redirected` | 审批任务已转交 | 无 | | `user_oa_approval_instance_started` | 审批实例已发起 | 无 | +| `user_oa_approval_instance_cc` | 审批实例到达抄送节点,发送给被抄送人 | 无 | | `user_oa_approval_instance_terminated` | 审批实例已终止 | 无 | | `user_oa_approval_instance_finished` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 22 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。六个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。 +只承认上表 23 个事件码。默认身份就是当前用户,使用当前用户 OAuth 登录态,不要额外加身份切换 flag。七个 OA 事件订阅当前用户相关的全部审批事件,规则均为 `all`、空 `filterRule`,不需要目标参数。 ## Intent mapping @@ -76,9 +77,10 @@ | "审批任务完成时通知我" | `event consume`,事件码 `user_oa_approval_task_finished`,参数 `--flatten -f ndjson` | | "审批任务被转交时通知我" | `event consume`,事件码 `user_oa_approval_task_redirected`,参数 `--flatten -f ndjson` | | "有审批单发起时通知我" | `event consume`,事件码 `user_oa_approval_instance_started`,参数 `--flatten -f ndjson` | +| "有审批抄送给我时通知我" | `event consume`,事件码 `user_oa_approval_instance_cc`,参数 `--flatten -f ndjson` | | "有审批单终止时通知我" | `event consume`,事件码 `user_oa_approval_instance_terminated`,参数 `--flatten -f ndjson` | | "监听我发起的审批何时完成" / "审批实例完成时通知我" | `event consume`,事件码 `user_oa_approval_instance_finished`,参数 `--flatten -f ndjson` | -| "同时监听全部已公开 OA 事件" | 一个 consume 放入六个 OA event key,不加目标或消息过滤参数 | +| "同时监听全部已公开 OA 事件" | 一个 consume 放入七个 OA event key,不加目标或消息过滤参数 | | "查看个人事件 schema" | `dws event schema --flatten` | | "看个人事件订阅状态" | `dws event status --event ` | | "停止这个个人事件订阅" | `dws event stop --dry-run`,确认后改用 `--yes` | @@ -130,6 +132,7 @@ dws event schema user_oa_approval_task_created --flatten dws event schema user_oa_approval_task_finished --flatten dws event schema user_oa_approval_task_redirected --flatten dws event schema user_oa_approval_instance_started --flatten +dws event schema user_oa_approval_instance_cc --flatten dws event schema user_oa_approval_instance_terminated --flatten dws event schema user_oa_approval_instance_finished --flatten ``` @@ -157,6 +160,7 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson dws event consume user_oa_approval_task_finished --flatten -f ndjson dws event consume user_oa_approval_task_redirected --flatten -f ndjson dws event consume user_oa_approval_instance_started --flatten -f ndjson +dws event consume user_oa_approval_instance_cc --flatten -f ndjson dws event consume user_oa_approval_instance_terminated --flatten -f ndjson dws event consume user_oa_approval_instance_finished --flatten -f ndjson ``` @@ -185,13 +189,14 @@ dws event consume \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten \ -f ndjson ``` -用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。六个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。 +用户类事件共享 `--user` 或 `--open-dingtalk-id`,群类事件共享 `--group`,无目标 IM 事件可加入任一组合。用户类与群类、不同目标或不同过滤条件要拆成多个进程。七个 OA 事件可以同进程消费并共享 personal bus,但各自建立独立订阅。多事件共享 `--query` / `--filter-json` 时,所选事件必须全部是 IM 消息接收事件;OA 事件单独或组合消费都禁止使用这两个消息过滤参数。 上述所有 `*_o2o` 命令和 `user_im_message_receive_user` 都可将 `--user ` 替换为 `--open-dingtalk-id `,但两个参数不能同时使用。 @@ -216,7 +221,7 @@ dws event stop --all --yes ## 订阅创建失败与重试预算 -以下约束适用于上表全部 22 个公开个人事件(16 个 IM + 6 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。 +以下约束适用于上表全部 23 个公开个人事件(16 个 IM + 7 个 OA)以及多事件命令中的每一项,只治理 `[event] ready` 之前的订阅创建;ready 之后的 Stream 断线由长连接重连机制处理。 - `0/2/1` 是 **Agent/host 编排约束**,不是 CLI 持久化硬总次数上限。每次 `dws event consume` 调用对每个逻辑订阅最多发送一次订阅创建 HTTP 请求,进程内不会自动重试。CLI 本地状态只持久化 `in_flight`、`cooldown`、`terminal_hold` 三种保护状态,不持久化或计算跨调用的 Agent/host 尝试次数。 - 解析人名或群名、执行 `event consume` 以及后续 `event status/stop` 必须使用同一个 `--profile`。不得把其它 profile 下解析出的 userId、openDingtalkId 或 openConversationId 直接带入当前 profile 的订阅。 diff --git a/skills/mono/references/products/oa.md b/skills/mono/references/products/oa.md index 332d8649..97f32e3d 100644 --- a/skills/mono/references/products/oa.md +++ b/skills/mono/references/products/oa.md @@ -27,13 +27,14 @@ Flags: --instance-id string 审批实例 ID (必填) ``` -### 审批附件授权与下载 +### 审批附件授权、上传与下载 先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` +- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` #### 获取审批附件临时下载链接 @@ -78,6 +79,21 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 +#### 上传本地文件为审批附件 + +``` +Usage: + dws oa approval attachment upload [flags] +Example: + dws oa approval attachment upload --file ./合同.pdf --format json +Flags: + --file string 本地文件路径 (必填) + --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) +``` + +该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -292,7 +308,7 @@ Flags: - **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。 - **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。 -- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。 +- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file ` 获取字段再组装为 value 提交,不属于不支持类型。 - **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。** - **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。 - **严禁在未得到用户确认前直接执行真实提单。** @@ -411,7 +427,7 @@ Flags: | 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 | | 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 | | 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** | -| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 | +| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file ` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 | | 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) | | 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 | | 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 | @@ -426,9 +442,19 @@ Flags: - `CalculateField`(计算公式)— 由系统自动计算 - `SeqNumberField`(流水号)— 由系统自动生成 - `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互 -- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取 - **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作 +> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file ` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例: +> +> ```bash +> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType +> dws oa approval attachment upload --file ./a.pdf +> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes +> dws oa approval create-instance --process-code PROC-xxx \ +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' +> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes +> ``` + > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。 @@ -634,6 +660,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -721,6 +771,7 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` @@ -743,6 +794,7 @@ Flags: 用户说"我发起的审批单" -> `approval list-submitted` 用户说"我审批/处理过的审批单" -> `approval list-executed` 用户说"抄送我的审批单" -> `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -792,6 +844,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 contact user search) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -834,6 +889,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `forecast-process` | `workflowActivityRuleVOs`(`activityId`, `targetSelect`, `activityActioners`, `workflowActor`) | ① 向用户展示流程走向和各节点处理人;② `targetSelect: true` 的节点需用户自选审批人,`workflowActor.actorKey` 作为 `targetSelectActioners` 的 `actionerKey` 传入 create-instance | | `search-forms` → `form-schema` → `forecast-process` | `processCode` → 字段定义 → 流程走向 + 自选节点 | create-instance 的完整上下文:表单值 + 流程路径 + targetSelectActioners | | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -844,6 +900,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取。当 `list-forms` 返回 `processCodeList` 为空(`totalCount -1`)时,用 `search-forms --query <表单名>`(如 `--query 报销`)按名称精准拿 `processCode` 更稳 - `list-initiated` 的 `--start` / `--end` 区间有后端上限(约 120 天)。超过上限会返回误导性的 `business_error: 时间戳无效`(实为区间过长,不是时间格式问题)。跨度大时请拆成多段短区间分别查询 +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - `form-schema` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取;返回的 `content` 字段为 JSON 字符串,需解析后查看表单组件(items)定义。 - `create-instance` 发起前**必须先阅读** [oa-form-components.md](oa/oa-form-components.md)(控件值格式)和 [oa-process-nodes.md](oa/oa-process-nodes.md)(流程节点规则),再调用 `form-schema` 获取表单字段定义,确保 `--form-values` 中的 key 与控件 label 完全一致。 diff --git a/skills/mono/references/products/oa/oa-form-components.md b/skills/mono/references/products/oa/oa-form-components.md index 5e795ce4..7da45277 100644 --- a/skills/mono/references/products/oa/oa-form-components.md +++ b/skills/mono/references/products/oa/oa-form-components.md @@ -239,14 +239,14 @@ ### DDAttachment(附件控件) -> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。 +> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file ` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。 | 属性 | 说明 | |------|------| | `componentName` | `DDAttachment` | | value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId | | 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` | -| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 | +| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file ` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 | ### StarRatingField(评分控件) @@ -328,7 +328,6 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 diff --git a/skills/mono/references/products/recruit.md b/skills/mono/references/products/recruit.md new file mode 100644 index 00000000..93502d4c --- /dev/null +++ b/skills/mono/references/products/recruit.md @@ -0,0 +1,39 @@ +# 钉钉招聘 + +`dws recruit` 提供招聘职位列表、职位详情和职位创建能力。组织、业务标识和当前 +操作人由登录身份及 MCP Connector 注入,不要要求用户提供或猜测身份字段。 + +## 查询 + +```bash +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job get --job-id JOB_ID --format json +``` + +列表状态支持 `draft`、`open`、`invalid`、`closed`。首页不传 `--cursor`; +`hasMore=true` 时把响应 `nextCursor` 回填。查询详情前必须从真实列表结果取得 +`jobId`。 + +## 创建 + +创建是非幂等写操作。先用 `--dry-run` 检查;实际执行必须经过用户明确确认,并 +交由 CLI 的确认流程处理: + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +JSON 必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`。 +`jobNature` 当前固定为 `FULL-TIME`;学历枚举为 1小学、2初中、3高中、4中专、 +5大专、6本科、7硕士、8博士、9其他。`minSalary`、`maxSalary` 可选;两者同时 +提供时最低薪资不得高于最高薪资。 + +`creatorUserId` 是当前创建人 userId,必须来自同一 profile 下的真实通讯录结果。 +`ownerUserIds` 可选;提供时是负责人 userId 的 JSON 字符串数组,也必须使用真实 +通讯录结果。禁止猜测任何 userId。`extData.headCount` 范围 1–999,薪资月份范围 +12–24;提供地址时必须包含地点名、详细地址和可信的经纬度。 + +文件只写职位对象,不要添加 MCP 信封字段 `atsAddJobParam`、`corpId`、`bizCode` +或 `opUserId`;这些字段由 CLI 或 Connector 处理。 diff --git a/skills/mono/references/products/sheet.md b/skills/mono/references/products/sheet.md index 4276456d..6b45e267 100644 --- a/skills/mono/references/products/sheet.md +++ b/skills/mono/references/products/sheet.md @@ -54,6 +54,7 @@ | 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | +| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | ## 触发必读 @@ -63,6 +64,7 @@ - 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。 - 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。 - 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。 +- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。 ## Reference 索引 @@ -82,13 +84,14 @@ | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | -| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` | | [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | | [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | | [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | | [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | | sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` | -| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` | +| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` | +| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` | ## 全局硬约束 @@ -99,12 +102,12 @@ 5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` 6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` 7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV -8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段 +8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除 9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段 10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` 11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` 12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) -13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 +13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) 15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` 16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` @@ -115,9 +118,10 @@ - **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。 - **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 - **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 -- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 +- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。 - **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 -- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 +- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。 +- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 ## URL 粘贴场景 @@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token --name dws sheet import create --file ./data.xls --workspace ``` - 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 -- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** +- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试** - 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node` - **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格 diff --git a/skills/mono/references/products/sheet/sheet-comment.md b/skills/mono/references/products/sheet/sheet-comment.md index 13a614f4..b8d58280 100644 --- a/skills/mono/references/products/sheet/sheet-comment.md +++ b/skills/mono/references/products/sheet/sheet-comment.md @@ -12,7 +12,7 @@ - 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 - 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 - `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 -- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 +- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。 --- diff --git a/skills/mono/references/products/sheet/sheet-dropdown.md b/skills/mono/references/products/sheet/sheet-dropdown.md index 05b5f0c0..07c32d65 100644 --- a/skills/mono/references/products/sheet/sheet-dropdown.md +++ b/skills/mono/references/products/sheet/sheet-dropdown.md @@ -66,7 +66,7 @@ Flags: 查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -99,5 +99,5 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 -- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 +- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/mono/references/products/sheet/sheet-export.md b/skills/mono/references/products/sheet/sheet-export.md index ba0880c6..8c067aff 100644 --- a/skills/mono/references/products/sheet/sheet-export.md +++ b/skills/mono/references/products/sheet/sheet-export.md @@ -5,11 +5,11 @@ ### 导出 用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel": -- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载) +- 导出表格 → `export`(单命令会自动等待完成,并可选下载) - 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl) - 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx -- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性 +- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": - 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) @@ -38,26 +38,14 @@ Flags: --output string 本地保存路径(可选,支持文件路径或目录) ``` -将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `submit_export_job` 获取 `jobId` -2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 -3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 +将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。 **命令返回**: - `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载) - `--output` 指定为文件路径:下载到该路径并输出 `导出完成: ` - `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下 -**失败处理(命令内部已处理,Agent 仅需转述)**: +**失败处理**: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 @@ -111,7 +99,7 @@ dws sheet export-csv --node --sheet-id --allow-truncated -- ```bash # ── 工作流 12: 导出表格为 xlsx(单命令一站式)── -# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) +# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl) dws sheet export --node --format json # 传入 URL 也可: # dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json @@ -122,7 +110,7 @@ dws sheet export --node --output ./report.xlsx # 场景 C:下载到目录,自动按链接推断文件名 dws sheet export --node --output ./ -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 +# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。 # 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 ``` @@ -130,18 +118,18 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 | | `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | | `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 - ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` -- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 +- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 -- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性 - `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` - ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/mono/references/products/sheet/sheet-filter.md b/skills/mono/references/products/sheet/sheet-filter.md index 0e65607b..90bfe695 100644 --- a/skills/mono/references/products/sheet/sheet-filter.md +++ b/skills/mono/references/products/sheet/sheet-filter.md @@ -173,7 +173,7 @@ Flags: - `filter update` 前置:工作表必须已创建筛选 - `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等) - `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选 -- ★ **筛选操作规范**(参照飞书 core-operations): +- ★ **筛选操作规范**: - 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替 - 创建/更新筛选后**必须** `filter get` 回读验证配置正确 - 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作 diff --git a/skills/mono/references/products/sheet/sheet-import.md b/skills/mono/references/products/sheet/sheet-import.md index d50b27b4..b301e4bf 100644 --- a/skills/mono/references/products/sheet/sheet-import.md +++ b/skills/mono/references/products/sheet/sheet-import.md @@ -6,7 +6,7 @@ - 支持本地 `xlsx` / `xls`,文件上限 20MB - `--folder-token` 与 `--workspace` 至少提供一个 -- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 +- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试 - `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 ## 命令 diff --git a/skills/mono/references/products/sheet/sheet-media-image.md b/skills/mono/references/products/sheet/sheet-media-image.md index 06aba4b7..d3e236f0 100644 --- a/skills/mono/references/products/sheet/sheet-media-image.md +++ b/skills/mono/references/products/sheet/sheet-media-image.md @@ -228,8 +228,8 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 -- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 +- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤 +- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 - `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL diff --git a/skills/mono/references/products/sheet/sheet-pivot-table.md b/skills/mono/references/products/sheet/sheet-pivot-table.md index 6b8f93d2..2e451972 100644 --- a/skills/mono/references/products/sheet/sheet-pivot-table.md +++ b/skills/mono/references/products/sheet/sheet-pivot-table.md @@ -191,7 +191,7 @@ Flags: - 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` - 必须包含表头行(通常从第 1 行开始) - sheet 名称用单引号包裹(含空格或特殊字符时必须) -- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 +- `source` 直接使用上述 A1 表示法,不要改写成其他结构 ## 高级功能示例 @@ -210,7 +210,7 @@ dws sheet pivot-table create --node \ }' ``` -> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。 +> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。 ## 核心工作流 diff --git a/skills/mono/references/products/sheet/sheet-read-data.md b/skills/mono/references/products/sheet/sheet-read-data.md index f8008d0e..89fef5a2 100644 --- a/skills/mono/references/products/sheet/sheet-read-data.md +++ b/skills/mono/references/products/sheet/sheet-read-data.md @@ -54,7 +54,7 @@ Flags: - `rowIndices` — 行号映射数组(如 `[1,2,3]`) - `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 - `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 -- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围 - `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -161,7 +161,7 @@ Flags: | `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | | `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | | `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | -| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 @@ -270,6 +270,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 - ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` +- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。 - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 - `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表 diff --git a/skills/mono/references/products/sheet/sheet-revision-changeset.md b/skills/mono/references/products/sheet/sheet-revision-changeset.md new file mode 100644 index 00000000..13e5b662 --- /dev/null +++ b/skills/mono/references/products/sheet/sheet-revision-changeset.md @@ -0,0 +1,306 @@ +# 工作簿 revision 与 changeset + +## 何时使用 + +当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。 + +- 当前 revision → `dws sheet revision-get` +- revision 区间内的变更 → `dws sheet changeset-get` +- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md) +- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令 + +两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。 + +revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。 + +当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。 + +## 获取当前 revision + +```bash +dws sheet revision-get --node --format json +``` + +成功时从统一输出的 `data.revision` 读取当前 revision: + +```json +{ + "ok": true, + "outcome": "success", + "data": { + "success": true, + "logId": "trace-id", + "revision": 142 + } +} +``` + +- `revision=0` 是合法的空工作簿基线。 +- revision 不是工作表数量,也不是历史版本列表序号。 +- `logId` 仅用于失败反馈和问题排查,不是 revision。 + +## 获取 revision 区间 changeset + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --end-revision 140 \ + --format json +``` + +也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision: + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --format json +``` + +参数规则: + +- `--start-revision` 必填,必须为非负整数;`0` 合法。 +- `--end-revision` 可选;提供时必须大于或等于 start。 +- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。 +- `startRevision == endRevision` 合法,返回空 `changesets`。 +- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。 +- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。 +- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。 + +成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。 + +## 先看区间摘要 + +`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响: + +| 字段 | 含义 | +|------|------| +| `changeCount` | `changes[]` 中变更对象的总数 | +| `completeChangeCount` | 详情完整的变更数量 | +| `partialChangeCount` | 只返回部分可信详情的变更数量 | +| `unsupportedChangeCount` | 当前无法解释的变更数量 | +| `containsStateReset` | 区间内是否发生整表状态替换 | +| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 | +| `affectedSheets` | 受影响工作表及其 A1 范围摘要 | + +`affectedSheets[]` 常用字段: + +| 字段 | 含义 | +|------|------| +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 | +| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` | + +摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。 + +## 读取 `changesets[]` + +每个 changeset 对应一个 revision: + +| 字段 | 含义 | 使用规则 | +|------|------|----------| +| `revision` | 该事件对应的 revision | 按升序解释 | +| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 | +| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 | +| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 | +| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 | +| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 | + +事件类型: + +- `EDIT`:一次普通编辑提交产生的效果。 +- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。 +- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。 + +changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。 + +## 读取 `reset` + +| 字段 | 含义 | +|------|------| +| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` | +| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 | +| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 | + +当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。 + +回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。 + +## 读取 `changes[]` + +每个 change 都按以下顺序读取: + +1. `type`:发生了哪类变更。 +2. `targets`:变更作用在哪个工作簿、工作表或范围。 +3. `detailsStatus`:详情是否完整。 +4. `details`:本次变更的可用详情。 +5. `omissions`:哪些详情未能提供。 + +公共字段: + +| 字段 | 含义 | +|------|------| +| `type` | 语义变更类型,见下方字典 | +| `targets` | 工作簿、工作表或 A1 范围定位数组 | +| `details` | 与 `type` 对应的变更详情;无详情时为空对象 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | +| `omissions` | 未提供详情的原因;没有省略时为空数组 | + +`detailsStatus` 的处理规则: + +- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。 +- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。 +- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。 + +`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。 + +## 读取 `targets[]` + +| 字段 | 含义 | +|------|------| +| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` | +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称 | +| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 | +| `a1Range` | 1-based A1 范围 | +| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 | + +- `SOURCE` 是读取或复制来源,不表示该位置被修改。 +- `DESTINATION` 是写入目标,应算作受影响位置。 +- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。 +- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。 + +## 变更类型字典 + +### 行列与工作表 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` | +| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` | +| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` | +| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` | +| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 | +| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` | +| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` | + +`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。 + +### 单元格与范围 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` | +| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` | +| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` | +| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` | +| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` | +| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` | +| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` | +| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` | +| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` | +| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` | +| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` | +| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` | + +粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。 + +`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。 + +`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。 + +### 分组、数据验证和结构对象 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` | +| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` | +| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` | +| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` | +| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` | +| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` | +| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` | + +扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。 + +`dataValidation` 常用字段: + +| 字段 | 含义 | +|------|------| +| `type` | 验证规则类型 | +| `sourceType` | 下拉来源:`inline` 或 `sourceRange` | +| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` | +| `sourceRange` | 已确认的来源工作表与 A1 范围 | +| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` | +| `sourceRangeExpression` | 可读的来源表达式 | +| `enableMultiSelect` | 是否允许多选 | +| `criteria` | 非下拉规则的运算符和条件值 | +| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 | + +只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。 + +### 工作簿设置与其他变更 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` | +| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` | +| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` | + +`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。 + +## 常用详情值 + +### 单元格内容 + +`cell` 常见字段: + +- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。 +- `formula`:本次写入的可读公式。 +- `formulaCleared=true`:本次清除了公式。 +- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。 +- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。 + +### 清除、null 与字段缺失 + +- 对象整体为 `{ "cleared": true }`:本次清除了该对象。 +- `formulaCleared=true`:本次清除了公式。 +- `styleCleared=true`:本次清除了该相对位置的样式。 +- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。 +- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。 + +不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。 + +## Agent 解读顺序 + +1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。 +2. 直接遍历 `data.changesets`,不要把它当作字符串。 +3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。 +4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。 +5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。 +6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。 +7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。 +8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。 +9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。 +10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。 + +## 最终状态回读 + +changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。 + +- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。 +- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。 +- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。 +- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。 +- `STATE_RESET`:重新读取工作簿当前状态。 +- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。 + +## 常见失败处理 + +- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。 +- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。 +- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。 +- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。 +- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。 diff --git a/skills/mono/references/products/sheet/sheet-style-format.md b/skills/mono/references/products/sheet/sheet-style-format.md index 20cb2b32..574c0d94 100644 --- a/skills/mono/references/products/sheet/sheet-style-format.md +++ b/skills/mono/references/products/sheet/sheet-style-format.md @@ -99,7 +99,7 @@ Flags: - `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 - 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) - 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 -- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 +- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并 ### 批量设置单元格样式(服务端原子事务) ``` @@ -161,7 +161,7 @@ Flags: - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** - 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) -- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 +- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域 ### 合并单元格 ``` @@ -304,7 +304,7 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 - `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 - `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 -- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) +- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/mono/references/products/sheet/sheet-version.md b/skills/mono/references/products/sheet/sheet-version.md index 3a119745..928e9ad4 100644 --- a/skills/mono/references/products/sheet/sheet-version.md +++ b/skills/mono/references/products/sheet/sheet-version.md @@ -5,10 +5,10 @@ 管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。 - 手动保存当前表格为一个版本快照 → `version save` -- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`) -- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认) +- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段 +- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认) -三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。 +三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。 ## 命令详细参考 @@ -38,32 +38,47 @@ Flags: --limit int 返回版本数量上限 (可选) --cursor string 分页游标 (可选,游标分页) ``` -返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。 +返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。 ### 回滚表格到指定版本 ``` Usage: dws sheet version revert [flags] Example: - dws sheet version revert --node SHEET_ID --version 3 --yes + dws sheet version revert --node SHEET_ID --version 3 Flags: --node string 表格文档 ID 或 URL (必填) - --version int 目标版本号 (必填,从 version list 获取) + --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。 + +`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 + +### 精确 revision 回滚 + +只有用户明确要求恢复到某个 revision 时才走此流程: + +1. 用 `revision-get` 记录回滚前的当前 revision。 +2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 +3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。 +4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。 +5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 | +| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 | | `version save` | 版本快照结果 | 确认已生成快照 | | `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 | ## 注意事项 -- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` -- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号 -- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可 +- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认 +- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision +- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 +- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) - 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成" diff --git a/skills/mono/references/products/sheet/sheet-workbook.md b/skills/mono/references/products/sheet/sheet-workbook.md index 54c4ca48..8299ba06 100644 --- a/skills/mono/references/products/sheet/sheet-workbook.md +++ b/skills/mono/references/products/sheet/sheet-workbook.md @@ -70,12 +70,12 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 +本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。 **写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 -- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) @@ -104,7 +104,7 @@ Flags: **行为要点**: - 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 -- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 命令会等待新建表格可写后再写入数据,并通过回读确认结果 - 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 diff --git a/skills/multi/dingtalk-chat/SKILL.md b/skills/multi/dingtalk-chat/SKILL.md index d1e62ea1..a6fb537a 100644 --- a/skills/multi/dingtalk-chat/SKILL.md +++ b/skills/multi/dingtalk-chat/SKILL.md @@ -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 --name <新名称>` | 只知群名时先用 `+chat-search --query <群名>` 唯一解析 ID;不猜 `+chat-rename` | | 查看指定群内 @我的消息 | `dws chat +at-me --group <群名> --page-all` | 检查 `complete`;空结果仍返回数组 | | 查看全部会话 | `dws chat +conversation-list --page-all` | 检查 `complete` / `failures` | diff --git a/skills/multi/dingtalk-chat/references/chat.md b/skills/multi/dingtalk-chat/references/chat.md index cb46df88..3b8e710f 100644 --- a/skills/multi/dingtalk-chat/references/chat.md +++ b/skills/multi/dingtalk-chat/references/chat.md @@ -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`。 ## 群与成员底层能力 diff --git a/skills/multi/dingtalk-doc/SKILL.md b/skills/multi/dingtalk-doc/SKILL.md index 534cef40..5e67eff3 100644 --- a/skills/multi/dingtalk-doc/SKILL.md +++ b/skills/multi/dingtalk-doc/SKILL.md @@ -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 ` | `--scope keyword/section/range` 可减少无关正文;非 adoc 切换对应产品 | +| 最近访问文档 | `dws doc +search`(省略 `--query`) | `--limit` 为每页量,`--max-items` 为总上限;完整集合才加 `--page-all` 并检查 `complete` | +| 已知 ID/URL 读取正文或局部内容 | `dws doc +fetch --node ` | 具体术语直用 `keyword`;章节先 `outline` 再 `section`;只有整篇任务才用默认 `full` | | 聚合查看信息、权限、版本、媒体或评论 | `dws doc +inspect --node ` | 仅打开任务所需的 `--include-*`,不要默认全取 | -| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 长或复杂 Markdown 使用 `@file`/stdin;根据结果验证真实 `nodeId` | -| 追加、覆盖或精确编辑 block | `dws doc +update --node --command <动作>` | `--expected-revision` 仅用于 JSONML 原子覆盖;先消费 Schema confirmation | +| 新建在线文字文档并写入内容 | `dws doc +create --name <标题> --content @<相对文件>` | 先在本地完成正文;Runtime 负责 Markdown 分片和回读,禁止 Agent 自行拆成多次远程写入 | +| 追加、覆盖或精确编辑 block | `dws doc +update --node --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 +" --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 diff --git a/skills/multi/dingtalk-doc/references/contracts.md b/skills/multi/dingtalk-doc/references/contracts.md index a75ff6d1..544382e1 100644 --- a/skills/multi/dingtalk-doc/references/contracts.md +++ b/skills/multi/dingtalk-doc/references/contracts.md @@ -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` 字段。 ## 安全落盘 diff --git a/skills/multi/dingtalk-doc/references/doc.md b/skills/multi/dingtalk-doc/references/doc.md index 76570e83..9ecdfc67 100644 --- a/skills/multi/dingtalk-doc/references/doc.md +++ b/skills/multi/dingtalk-doc/references/doc.md @@ -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 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-create.md b/skills/multi/dingtalk-doc/references/doc/doc-create.md index c5a23e3e..3e06ff19 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-create.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-create.md @@ -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 创建脚本。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-import.md b/skills/multi/dingtalk-doc/references/doc/doc-import.md index c2bfe44e..d03a6031 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-import.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-import.md @@ -8,14 +8,15 @@ dws doc +import --file ./report.docx --folder --format json dws doc +import --file ./notes.md --workspace --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 `,不要因容器叫“文档空间”就误报为在线文档。 ## 失败处理 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-media.md b/skills/multi/dingtalk-doc/references/doc/doc-media.md index b87c2e03..87a5e694 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-media.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-media.md @@ -16,10 +16,22 @@ dws doc +media-download --node --resource-id --output ./d dws doc +media-preview --node --resource-id --format json ``` +## 封面与背景 Shortcut + +```bash +dws doc +resource-update --node --file ./cover.png --format json +dws doc +resource-download --node --output ./cover.png --format json +dws doc +resource-delete --node --format json +dws doc +background-update --node --color "#E8F2FE" --format json +dws doc +background-delete --node --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。 - 删除源文件是独立的破坏性本地操作,不属于媒体下载;只有用户明确要求且下载验证成功后才能执行。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-read.md b/skills/multi/dingtalk-doc/references/doc/doc-read.md index dc8dd710..ed7857fc 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-read.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-read.md @@ -24,10 +24,24 @@ dws doc +fetch --node --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` 后,后续调用直接复用,避免再次按标题搜索。 diff --git a/skills/multi/dingtalk-doc/references/doc/doc-update.md b/skills/multi/dingtalk-doc/references/doc/doc-update.md index 502103c7..59707ac4 100644 --- a/skills/multi/dingtalk-doc/references/doc/doc-update.md +++ b/skills/multi/dingtalk-doc/references/doc/doc-update.md @@ -4,6 +4,8 @@ 普通追加、覆盖和 block 编辑统一使用 `+update`: +`--command` 只接受下列枚举值,不接受 JSON、自然语言或拼接子命令;动作参数必须分别传给 `--content/--old/--new/--block-id/--after-block-id`。 + ```bash dws doc +update --node --command append --content "补充说明" --format json dws doc +update --node --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` 只恢复未完成步骤,不能重放成功步骤。 diff --git a/skills/multi/dingtalk-event/SKILL.md b/skills/multi/dingtalk-event/SKILL.md index 4fdd75a9..719c3d64 100644 --- a/skills/multi/dingtalk-event/SKILL.md +++ b/skills/multi/dingtalk-event/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-event -description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/终止/完成。命令前缀:dws event。 +description: 钉钉个人 IM 与 OA 审批事件长连接监听。Use when 用户说监听消息/@我/某人/某群/全部消息、已读/撤回/reaction、群成员加入/群成员退出/群状态变化,或监听审批任务创建/完成/转交、审批实例发起/抄送/终止/完成。命令前缀:dws event。 metadata: cli_version: ">=0.2.14" category: product @@ -45,7 +45,7 @@ metadata: - `group` 必须且只能传 `--chat-id` 或 `--chat-query` 之一。 - `--query` 只用于纯 `message` 监听;混入 reaction/read/recall 时不得使用。 -OA 事件不进入 `+listen-im`。六个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。六项可放入同一个 consume,每项建立独立订阅并共享 bus。 +OA 事件不进入 `+listen-im`。七个公开 OA EventKey 都订阅当前 OAuth 用户相关的全部审批事件,使用 `ruleType=all`、`filterRule={}`;不接受 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。七项可放入同一个 consume,每项建立独立订阅并共享 bus。 自然姓名和群名由 CLI 内部唯一解析:零命中或多候选返回结构化失败,在创建任何订阅前停止。`--dry-run` 走同一解析链。解析、监听、状态和停止必须使用同一个 `--profile`,不得跨组织搬运 ID。 @@ -65,7 +65,7 @@ user_im_group_member_added user_im_group_member_exited user_im_group_disbanded ``` -六个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。 +七个 OA EventKey 及其输出字段见 [OA 事件参考](references/event-oa.md)。 用户类事件传 `--user` 或 `--open-dingtalk-id`,群类事件传 `--group`。群生命周期输出可含 `operator_open_dingtalk_id` 和 `members`;成员项使用 `open_dingtalk_id`。精确组合、兼容性和 Filter 规则见 reference。 @@ -98,7 +98,7 @@ kind + events + target - `event stop` 会取消订阅并影响本地 consumer:先 `--dry-run`,用户确认后再加 `--yes`。 - 多事件属于一次原始操作;任一订阅启动失败时 Runtime 回滚本次已创建项,不拆成新命令绕过重试预算。 -- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 22 个公开个人 EventKey(16 个 IM + 6 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。 +- 这套 `0/2/1` 是 **Agent/host** 编排预算,适用于全部 23 个公开个人 EventKey(16 个 IM + 7 个 OA):`retryable=false` 对应 `max_additional_attempts=0`;`retryable=true` 对应 `max_additional_attempts=2`;`retryable=unknown` 对应 `max_additional_attempts=1`。它不是 CLI 持久化硬总次数上限;每次调用最多创建一次,进程内不会自动重试,CLI 也不持久化或计算跨调用的 Agent/host 尝试次数。 - 重试必须遵守 `retry_after_seconds` / `next_retry_at`。遇到 `in_flight`、`cooldown`、`terminal_hold` 不并发或递归重启同一逻辑订阅,也不换 `subscribe_id` / `trace_id` 绕过保护。 - 认证、profile、订阅保护状态和 bus 排障按失败类型读取 [订阅运维](references/event-im-operations.md),不要在正常路径预加载完整运维手册。 @@ -125,4 +125,4 @@ kind + events + target | ready、bounded consume 与退出清理 | [event-im-lifecycle.md](references/event-im-lifecycle.md) | 启动/托管/关闭 consumer | | 扁平字段与事件到 Chat 交接 | [event-im-output.md](references/event-im-output.md) | 解析事件或自动回复 | | Filter、status/stop、重试与排障 | [event-im-operations.md](references/event-im-operations.md) | 订阅控制或失败恢复 | -| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择六个 OA EventKey、组合消费或解析审批字段 | +| OA 审批事件 | [event-oa.md](references/event-oa.md) | 选择七个 OA EventKey、组合消费或解析审批字段 | diff --git a/skills/multi/dingtalk-event/references/event-oa.md b/skills/multi/dingtalk-event/references/event-oa.md index 6d7ec4e2..e8af0108 100644 --- a/skills/multi/dingtalk-event/references/event-oa.md +++ b/skills/multi/dingtalk-event/references/event-oa.md @@ -1,6 +1,6 @@ # OA 个人审批事件 -先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的六个 OA 个人事件:审批实例发起、终止和完成,以及审批任务创建、完成和转交。 +先读事件产品入口 [SKILL.md](../SKILL.md) 的命令规则、调用流和子进程契约。本参考覆盖当前公开的七个 OA 个人事件:审批实例发起、抄送、终止和完成,以及审批任务创建、完成和转交。 实时监听审批事件必须使用 `dws event consume` 长连接,不要轮询 OA 待办或审批实例列表来模拟事件。 @@ -22,10 +22,11 @@ dws auth login | `user_oa_approval_task_finished` | `all` | 审批任务已完成 | 无 | | `user_oa_approval_task_redirected` | `all` | 审批任务已转交 | 无 | | `user_oa_approval_instance_started` | `all` | 审批实例已发起 | 无 | +| `user_oa_approval_instance_cc` | `all` | 审批实例到达抄送节点,发送给被抄送人 | 无 | | `user_oa_approval_instance_terminated` | `all` | 审批实例已终止 | 无 | | `user_oa_approval_instance_finished` | `all` | 审批实例完成,发送给审批单发起人 | 无 | -只承认上表 6 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。 +只承认上表 7 个 OA 事件码。CLI 为每个事件发送 `ruleType=all`、`filterRule={}` 的独立订阅请求;不要添加 `--user`、`--open-dingtalk-id`、`--group`、`--query` 或 `--filter-json`。 ## Intent mapping @@ -35,13 +36,14 @@ dws auth login | “审批任务完成时通知我” | `dws event consume user_oa_approval_task_finished --flatten -f ndjson` | | “审批任务被转交时通知我” | `dws event consume user_oa_approval_task_redirected --flatten -f ndjson` | | “有审批单发起时通知我” | `dws event consume user_oa_approval_instance_started --flatten -f ndjson` | +| “有审批抄送给我时通知我” | `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` | | “有审批单终止时通知我” | `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` | | “监听我发起的审批何时完成” / “审批实例完成时通知我” | `dws event consume user_oa_approval_instance_finished --flatten -f ndjson` | -| “同时监听全部已公开 OA 事件” | 一个 consume 放入六个 OA event key,不加目标或过滤参数 | +| “同时监听全部已公开 OA 事件” | 一个 consume 放入七个 OA event key,不加目标或过滤参数 | | “查看 OA 事件目录” | `dws event list --category oa` | | “查看 OA 事件输出字段” | 对对应事件运行 `dws event schema --flatten` | -三个审批任务事件分别表达任务已创建、已完成和已转交;三个审批实例事件分别表达实例已发起、已终止和已完成。扁平字段来自六类事件的预发联调样本;`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。 +三个审批任务事件分别表达任务已创建、已完成和已转交;四个审批实例事件分别表达实例已发起、到达抄送节点、已终止和已完成。`status` 和 `result` 保留服务端原值,不把当前样本值推断为完整枚举。 ## Commands @@ -52,6 +54,7 @@ dws event schema user_oa_approval_task_created --flatten dws event schema user_oa_approval_task_finished --flatten dws event schema user_oa_approval_task_redirected --flatten dws event schema user_oa_approval_instance_started --flatten +dws event schema user_oa_approval_instance_cc --flatten dws event schema user_oa_approval_instance_terminated --flatten dws event schema user_oa_approval_instance_finished --flatten ``` @@ -63,11 +66,12 @@ dws event consume user_oa_approval_task_created --flatten -f ndjson dws event consume user_oa_approval_task_finished --flatten -f ndjson dws event consume user_oa_approval_task_redirected --flatten -f ndjson dws event consume user_oa_approval_instance_started --flatten -f ndjson +dws event consume user_oa_approval_instance_cc --flatten -f ndjson dws event consume user_oa_approval_instance_terminated --flatten -f ndjson dws event consume user_oa_approval_instance_finished --flatten -f ndjson ``` -同时监听六种事件: +同时监听七种事件: ```bash dws event consume \ @@ -75,13 +79,14 @@ dws event consume \ user_oa_approval_task_finished \ user_oa_approval_task_redirected \ user_oa_approval_instance_started \ + user_oa_approval_instance_cc \ user_oa_approval_instance_terminated \ user_oa_approval_instance_finished \ --flatten \ -f ndjson ``` -多事件 consume 会为六个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。 +多事件 consume 会为七个 event key 分别创建订阅和逻辑 consumer,并共享当前组织的 personal bus、远程连接、stdout 和生命周期。不要给 OA 命令加 `--query` 或 `--filter-json`;这两个 flag 只用于兼容的 IM 消息接收事件。 ## Output contract @@ -105,7 +110,7 @@ dws event consume \ - `type` 是当前 event key;`event_id` 可用于去重;`timestamp` 是 transport 事件发生时间;`subscribe_id` 标识对应的独立订阅。 - `process_instance_id` 是审批实例 ID,可传给 OA 审批命令的 `--instance-id`;`process_code` 是审批流程模板编码。 - `create_time`、`finish_time` 和 `event_time` 都是毫秒时间戳。`event_time` 是审批业务事件时间,`timestamp` 是 transport 事件时间。 -- 六类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。 +- 七类事件的额外字段如下;具体事件始终以 `dws event schema --flatten` 为准。 | 事件 | 额外顶层字段 | |---|---| @@ -113,6 +118,7 @@ dws event consume \ | `user_oa_approval_task_finished` | `task_id`、`result`、`finish_time` | | `user_oa_approval_task_redirected` | `task_id`、`result`、`finish_time` | | `user_oa_approval_instance_started` | 无 | +| `user_oa_approval_instance_cc` | 无 | | `user_oa_approval_instance_terminated` | `finish_time` | | `user_oa_approval_instance_finished` | `result`、`finish_time` | @@ -144,6 +150,6 @@ dws event consume \ ## Lifecycle - 单事件等待 `[event] ready event_key= bus_pid= subscribe_id=`。 -- 六事件先保存六条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count=6 bus_pid=`。 +- 七事件先保存七条 `[event] subscription event_key= subscribe_id=`,再等待 `[event] ready event_count=7 bus_pid=`。 - 临时验证使用 `--max-events 1` 或 `--duration 10m`;任务完成后优雅结束 consume,本次新建的订阅会自动取消。 - 外部停止已有订阅时先运行 `dws event stop --dry-run`,确认后再加 `--yes`。不要 `kill -9`,否则会跳过自动退订。 diff --git a/skills/multi/dingtalk-mail/SKILL.md b/skills/multi/dingtalk-mail/SKILL.md index 19b89174..8b2580a1 100644 --- a/skills/multi/dingtalk-mail/SKILL.md +++ b/skills/multi/dingtalk-mail/SKILL.md @@ -24,15 +24,13 @@ metadata: | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws mail +contact-list` | read | 列出指定邮箱的所有邮件联系人 | | `dws mail +find-mail-user` | read | 按关键词搜索邮箱联系人并投影列表(姓名/昵称/邮箱/工号等) | | `dws mail +folder-list` | read | 列出顶层文件夹或指定父文件夹下的子文件夹 | -| `dws mail +recent-mail` | read | 列出收件箱近期邮件会话并投影列表(主题/发件人/时间/threadId) | +| `dws mail +message` | read | 读取一封邮件的完整正文与附件元数据 | +| `dws mail +messages` | read | 按请求顺序读取多封邮件并逐封验证身份 | | `dws mail +search-mail` | read | 按 KQL 关键词搜索邮件并投影列表(主题/发件人/时间/messageId) | -| `dws mail +tag-list` | read | 列出指定邮箱下的所有邮件标签 | -| `dws mail +template-list` | read | 列出指定邮箱的所有邮件模板 | -| `dws mail +thread-list` | read | 列出指定邮箱文件夹下的邮件会话(thread) | -| `dws mail +unread-mail` | read | 列出未读邮件并投影列表(主题/发件人/时间/messageId) | +| `dws mail +thread` | read | 读取完整邮件会话并精确验证 conversationId | +| `dws mail +triage` | read | 列出或筛选邮件摘要,自动解析邮箱与收件箱 | | `dws mail +user-search` | read | 按关键词或工号搜索邮箱用户(仅企业邮箱) | diff --git a/skills/multi/dingtalk-misc/SKILL.md b/skills/multi/dingtalk-misc/SKILL.md index 487f8c7f..d4eed650 100644 --- a/skills/multi/dingtalk-misc/SKILL.md +++ b/skills/multi/dingtalk-misc/SKILL.md @@ -1,6 +1,6 @@ --- name: dingtalk-misc -description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 +description: 长尾产品集合技能,覆盖低频钉钉产品:OA审批查询与处理/考勤/直播/DING紧急消息/开放平台应用管理/Agoal目标管理/日志日报周报/电子表格/开放平台文档搜索/文档内嵌白板/钉钉招聘/DWS技能市场安装/组织大脑Hrbrain/原生Markdown/PAT行为授权/多组织profile。Use when 用户提到上述任一产品,或查待审批/同意拒绝转交撤销审批/打卡/排班/OKR/日报周报/单元格读写/白板节点读写/招聘职位/JD/创建职位/搜索安装技能/开发者后台应用/人才池/员工档案/职业历程/绩效/原生.md文件/PAT授权/切换组织/跨组织/profile 等相关操作。未来审批任务或实例变化的实时监听不属于本 skill,应使用 dingtalk-event。命中后由本 skill 的「产品索引表」定位具体子产品和命令前缀,再按对应子产品说明执行。 metadata: cli_version: ">=0.2.14" category: product @@ -31,6 +31,7 @@ metadata: | 电子表格 / 工作表 / 单元格读写 / 公式 / 超链接 / 浮动图片 | 电子表格创建/读写/公式/超链接/浮动图片/导出 | `dws sheet` | [sheet.md](references/sheet.md) | | 开放平台文档 / API文档 / 接口文档 / 接口报错 | 开放平台开发文档搜索 | `dws devdoc` | [devdoc.md](references/devdoc.md) | | 白板 / 画布 / OpenNodes / 白板节点 | 读取和更新钉钉文档中的内嵌白板 | `dws whiteboard` | [whiteboard.md](references/whiteboard.md) | +| 招聘 / 职位 / JD / 在招职位 / 创建职位 / 职位详情 | 钉钉招聘职位的查询、详情与创建 | `dws recruit` | [recruit.md](references/recruit.md) | | 搜索技能 / 找技能 / 安装技能 / 技能市场 / 安装 DWS mono 或 multi skill | DWS 技能市场搜索、下载、安装与内置技能部署 | `dws skill` | [skill.md](references/skill.md) | | 人才池 / 储备干部池 / 员工档案 / 职业历程 / 绩效记录 / 员工标签 / 组织大脑 / 人才搜索 | 组织大脑:人才池、员工档案专项模块与结构化人才搜索 | `dws hrbrain` | [hrbrain.md](references/hrbrain.md) | | 原生 Markdown / `.md` 原文 / 覆盖 Markdown / 局部替换 Markdown | 原生 `.md` 文件读取、创建、全量覆盖与局部替换 | `dws markdown` | [markdown.md](references/markdown.md) | diff --git a/skills/multi/dingtalk-misc/references/attendance.md b/skills/multi/dingtalk-misc/references/attendance.md index 00c70d03..994a6c4a 100644 --- a/skills/multi/dingtalk-misc/references/attendance.md +++ b/skills/multi/dingtalk-misc/references/attendance.md @@ -1417,23 +1417,12 @@ dws attendance boss-check --plan-id 948964045503 --time "2026-05-13 18:00" --res |---|---|---| | `dws attendance +check-record` | read | 查询用户打卡流水(打卡时间/地点/定位方式) | | `dws attendance +check-result` | read | 查询用户打卡结果(迟到/早退/缺卡等) | -| `dws attendance +get-adjustment-rule` | read | 根据补卡规则主键 ID 查询补卡规则详情 | | `dws attendance +get-approve-template` | read | 查询补卡/请假/加班/外出/出差审批提交链接 | -| `dws attendance +get-checkin-record` | read | 查询指定员工一段时间内的签到记录 | -| `dws attendance +get-leave-records` | read | 查询指定员工的假期余额变更记录 | | `dws attendance +get-overtime-rule` | read | 根据加班规则主键 ID 查询加班规则详情 | -| `dws attendance +get-schedule` | read | 获取指定用户一段时间内的排班记录 | -| `dws attendance +get-self-setting` | read | 查询个人规则设置(打卡提醒/极速打卡/缺卡提醒等) | -| `dws attendance +get-summary` | read | 查询某个人的考勤统计摘要(周/月) | | `dws attendance +list-approve` | read | 查询用户考勤审批单(补卡/加班/请假/出差外出) | -| `dws attendance +list-leave-types` | read | 查询当前用户可用的假期规则列表 | -| `dws attendance +my-attendance` | read | 查我今天的考勤打卡记录(打卡流水,自动解析当前用户) | -| `dws attendance +query-report-data` | read | 根据字段查询考勤报表数据(仅管理员) | | `dws attendance +search-adjustment-rule` | read | 查询当前用户可管理的补卡规则列表 | | `dws attendance +search-class` | read | 查询当前用户可管理的班次详情列表 | -| `dws attendance +search-group` | read | 查询当前用户可管理的考勤组列表 | | `dws attendance +search-overtime-rule` | read | 查询当前用户可管理的加班规则列表 | -| `dws attendance +this-month` | read | 查我本月的考勤打卡记录(打卡流水,自动解析当前用户) | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/ding.md b/skills/multi/dingtalk-misc/references/ding.md index 5cce1ce6..d2485596 100644 --- a/skills/multi/dingtalk-misc/references/ding.md +++ b/skills/multi/dingtalk-misc/references/ding.md @@ -173,10 +173,7 @@ dws ding message recall-personal --id --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws ding +list` | read | 查询 DING 消息列表 | -| `dws ding +recall-personal` | high-risk-write | 撤回本人发起的 DING | | `dws ding +receiver-status` | read | 查询 DING 消息接收人已读状态 | -| `dws ding +send-personal` | write | 以本人身份发送 DING 给指定人 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/oa.md b/skills/multi/dingtalk-misc/references/oa.md index 0f218814..4433beaa 100644 --- a/skills/multi/dingtalk-misc/references/oa.md +++ b/skills/multi/dingtalk-misc/references/oa.md @@ -31,13 +31,14 @@ Flags: --instance-id string 审批实例 ID (必填) ``` -### 审批附件授权与下载 +### 审批附件授权、上传与下载 先从 `approval detail` 的返回中取得审批实例 `processInstanceId`、附件 `fileId`,以及授权下载所需的 `spaceId`。根据目标选择命令: - 需要单个附件的临时下载链接:`attachment download-url` - 已有钉盘 `spaceId/fileId`,需要为当前用户批量开通下载权限:`attachment authorize-download` - 需要在审批场景内批量预览附件:`attachment authorize-preview` +- 需要把本地文件上传为审批附件(自动完成初始化+PUT+提交):`attachment upload` #### 获取审批附件临时下载链接 @@ -82,6 +83,21 @@ Flags: 该命令只授权审批场景内的附件预览,不等同于下载授权。附件来自审批评论时增加 `--with-comment-attachment`。 +#### 上传本地文件为审批附件 + +``` +Usage: + dws oa approval attachment upload [flags] +Example: + dws oa approval attachment upload --file ./合同.pdf --format json +Flags: + --file string 本地文件路径 (必填) + --file-name string 完整文件名,例如 合同.pdf (可选,默认取本地文件名) + --md5 string 文件原始字节内容的 MD5,32位十六进制字符串 (可选,不传则自动计算) +``` + +该命令一条命令完成审批附件上传的全部三步:先调用 `oa/init_attachment_upload_info` 初始化获取 OSS 上传地址与签名凭证,再将本地文件二进制 HTTP PUT 上传到 OSS,最后调用 `oa/commit_attachment_upload_info` 提交入库;返回结果包含 fileId、spaceId、fileName、fileSize、fileType。`--file-name` 不传时默认使用本地文件名,`--md5` 不传时自动根据文件内容计算,无需手动初始化或提交。 + ### 同意审批 > **CAUTION:** 审批决策不可撤回 — 执行前必须向用户确认。 @@ -295,7 +311,7 @@ Flags: - **严禁跳过 `form-schema`。** 未拿到表单 Schema 前,不得调用 `create-instance`。 - **严禁复用旧的 Schema 结果。** 每次发起实例前都必须重新调用 `form-schema`,模板可能已被修改。 -- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如附件等),直接告知用户不支持通过 CLI 发起。 +- **严禁在存在不支持必填控件时强行发起。** 若 `form-schema` 返回的必填控件中有不支持类型(如计算公式、流水号、OCR 等),直接告知用户不支持通过 CLI 发起。**注意:附件控件 `DDAttachment` 已支持通过 CLI 提交**,先用 `dws oa approval attachment upload --file ` 获取字段再组装为 value 提交,不属于不支持类型。 - **严禁把 `form-schema` 返回的 `content` 当成可直接提交的 payload 模板。** - **严禁把姓名直接写进 `approvers`、`ccList`、`directAppointedApprovers`、`targetSelectActioners` 或表单人员控件。** 必须先通过 `dws aisearch person --query "<姓名>" --dimension name --format json` 转成 userId。 - **严禁在未得到用户确认前直接执行真实提单。** @@ -414,7 +430,7 @@ Flags: | 部门控件 | `DepartmentField` | 部门 ID | `"12345"` | 多部门传 JSON 数组;multiple=true 时支持多选 | | 省市区控件 | `AddressField` | JSON 数组字符串 | `'["浙江省","杭州市","西湖区"]'` | 三级联动;needDetail=true 时末尾加详细地址 | | 图片控件 | `DDPhotoField` | URL 数组转义字符串 | `"[\"http://example.com/img1.jpg\"]"` | 支持 URL 直接提交;**不支持本地文件上传** | -| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **当前不支持通过 CLI 提交**,需钉盘上传接口获取 fileId 等字段 | +| 附件控件 | `DDAttachment` | JSON 数组转义字符串 | `"[{\"spaceId\":\"xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"xxx\"}]"` | **支持通过 CLI 提交**:先用 `dws oa approval attachment upload --file ` 获取 fileId/spaceId/fileName/fileSize/fileType,再组装为 DDAttachment value 提交 | | 评分控件 | `StarRatingField` | 数字字符串 | `"4"` | limit 控制最大星数(默认 5) | | 关联审批单 | `RelateField` | 审批实例 ID | `"q-xxx"` | 须为当前组织下已存在的实例 | | 明细控件 | `TableField` | JSON 数组字符串 | `'[{"子控件名":"值1"},{"子控件名":"值2"}]'` | 不可嵌套 TableField;不可含 DDMultiSelectField/DDPhotoField;最大 100 行 | @@ -429,9 +445,19 @@ Flags: - `CalculateField`(计算公式)— 由系统自动计算 - `SeqNumberField`(流水号)— 由系统自动生成 - `OcrTextField` / `OcrIdCardField`(OCR 识别)— 需要客户端交互 -- **`DDAttachment`(附件控件)— 当前不支持通过 CLI 提交**,value 需要 spaceId、fileName、fileSize、fileType、fileId 字段,须通过钉盘上传附件接口获取 - **套件类控件(暂不支持)** — `InvoiceField`(发票)、`RecipientAccountField`(收款账户)等业务套件控件当前暂不支持通过 CLI 发起,包含这些控件的审批模板请直接在钉钉客户端操作 +> **`DDAttachment`(附件控件)已支持通过 CLI 提交:** 采用两步流程——先用 `dws oa approval attachment upload --file ` 上传本地文件,返回 `fileId`、`spaceId`、`fileName`、`fileSize`、`fileType`;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。示例: +> +> ```bash +> # 1) 上传附件,拿到 fileId/spaceId/fileName/fileSize/fileType +> dws oa approval attachment upload --file ./a.pdf +> # 2) 组装 value 后先向用户展示提单汇总;确认前不要追加 --yes +> dws oa approval create-instance --process-code PROC-xxx \ +> --form-values '{"附件":"[{\"spaceId\":\"163xxx\",\"fileName\":\"a.pdf\",\"fileSize\":\"333\",\"fileType\":\"pdf\",\"fileId\":\"643xxx\"}]"}' +> # 用户确认模板、表单值、流程路径和人员后,才可在同一命令末尾追加 --yes +> ``` + > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**(见上方速查表),仅不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。 如果目标审批模板包含上述控件,不要硬拼 `form-values`;应告知用户这些字段无需填写或需要在钉钉客户端补充。 @@ -638,6 +664,30 @@ Flags: --query string 查询关键词,可选 ``` +### 以管理员身份查询审批实例列表 + +> **IMPORTANT:** 需要当前用户具备 OA 审批管理员权限,否则查不到数据。只查个人维度的审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。 + +``` +Usage: + dws oa approval list-by-admin [flags] +Example: + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 + dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --end "2026-03-10T23:59:59+08:00" --statuses RUNNING,COMPLETED --user-ids "userId1,userId2" + # 高级用法:传入完整 JSON(startTime/endTime 为 yyyy-MM-dd HH:mm:ss 格式字符串) + dws oa approval list-by-admin --request '{"processCode":"PROC-xxx","startTime":"2026-03-10 00:00:00","cursor":0,"pageSize":20}' +Flags: + --process-code string 审批模板 processCode(简单模式必填) + --start string 开始时间 ISO-8601 (如 2026-03-10T00:00:00+08:00)(简单模式必填) + --end string 结束时间 ISO-8601 (如 2026-03-10T23:59:59+08:00)(可选) + --cursor string 分页游标,首次传 0(默认 "0") + --limit string 每页大小,最大 20(默认 "20") + --user-ids string 按发起人 userId 过滤,多个用逗号分隔(可选) + --statuses string 按审批状态过滤,多个用逗号分隔(可选,如 RUNNING、TERMINATED、COMPLETED) + --request string 完整请求体 JSON(高级模式,与简单模式互斥) +``` +MCP 工具: `get_process_instances_by_admin`;参数封装在 `ProcessInstanceListQueryRequest`(processCode、startTime 必填,endTime、userIds、statuses、cursor、pageSize 可选;startTime/endTime 为 `yyyy-MM-dd HH:mm:ss` 格式字符串,简单模式的 ISO-8601 入参会自动转换)。processCode 可从 `list-forms` / `search-forms` 获取,返回的 processInstanceId 可用于 `detail` / `records` / `tasks`。 + ### 转交审批任务 ``` Usage: @@ -724,6 +774,7 @@ Flags: 用户说"下载审批附件/获取审批附件下载链接" → `approval attachment download-url`(需 --instance-id 和 --file-id;评论附件增加 --with-comment-attachment) 用户说"授权下载审批钉盘文件/批量开通附件下载权限" → `approval attachment authorize-download`(需 --file-infos,最多 10 项) 用户说"预览审批附件/批量授权预览附件" → `approval attachment authorize-preview`(需 --instance-id 和 --file-ids,最多 20 项;评论附件增加 --with-comment-attachment) +用户说"上传审批附件/把文件上传为审批附件" → `approval attachment upload`(需 --file;可选 --file-name 默认本地文件名、--md5 自动计算;一条命令完成 init+put+commit) 用户说"同意审批/批准" → 先 `tasks` 获取 taskId,再 `approve` 用户说"拒绝审批/驳回" → 先 `tasks` 获取 taskId,再 `reject` 用户说"撤回审批/取消审批" → `approval revoke` @@ -757,6 +808,7 @@ Flags: 用户说"抄送我的审批单/抄送我的XX审批/CC我的XX/查抄送我的XX" → `approval list-cc`,将 XX 作为 `--query` 关键字传入(可搜索表单名称或表单详情内容) - 示例:"查抄送我的补卡审批单" → `approval list-cc --query 补卡` - 示例:"抄送我的审批单"(无关键词)→ `approval list-cc` +用户说"以管理员身份查审批/全员审批单/统计某个模板的审批单/企业内审批记录" → `approval list-by-admin`(需 --process-code 和 --start,且当前用户需具备 OA 管理员权限) 用户说"转交审批/转交任务" → `approval redirect-task`(需 --task-id 和 --to-actioner-id) 用户说"评论审批/添加评论/写评论" → `approval oa-comments`(需 --instance-id 和 --content) 用户说"抄送审批/添加抄送人" → `approval oa-cc-noticer`(需 --instance-id 和 --users) @@ -815,6 +867,9 @@ dws oa approval list-submitted --limit --page --query # 11. 抄送我的审批单 dws oa approval list-cc --limit --page --query 关键词 --format json +# 11b. 以管理员身份跨用户查询某模板的审批实例列表(需 OA 管理员权限) +dws oa approval list-by-admin --process-code --start "2026-03-10T00:00:00+08:00" --cursor 0 --limit 20 --format json + # 12. 转交审批任务(taskId 来自 tasks,toActionerId 来自 aisearch person) dws oa approval redirect-task --task-id --to-actioner-id --format json dws oa approval redirect-task --task-id --to-actioner-id --remark "请帮忙处理" --format json @@ -879,6 +934,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | `create-instance` | `result`(processInstanceId) | detail / tasks / records / revoke 等的 --instance-id,可跟踪已发起的审批 | | `ding-info` | `userId` | ding message send 的 --users(多个逗号拼接);**robotCode 优先走 `$DINGTALK_DING_ROBOT_CODE` 环境变量,content 由 agent 根据审批上下文撰写;返回空时报错并停止** | | `revert-activities` | `activityId`, `revertAction`, `activityName` | revert-task 的 --target-activity-id 和 --action;**返回空时必须告知用户"无可回退节点"** | +| `list-by-admin` | `processInstanceId` | detail / records / tasks 的 --instance-id | ## 注意事项 @@ -900,6 +956,7 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 - `--remark` 审批意见虽为可选,但建议填写以留存审批痕迹 - `list-initiated` 的 `--process-code` 可从 `list-forms`、`search-forms` 或 `detail` 返回中提取 - 已知表单名称关键字时优先用 `search-forms`;需枚举全部表单时用 `list-forms` +- `list-by-admin` 需要当前用户具备 OA 审批管理员权限,否则查不到数据;只查个人维度审批时改用 `list-pending` / `list-executed` / `list-initiated` / `list-cc`。高级模式 `--request` 中 `startTime`/`endTime` 为 `yyyy-MM-dd HH:mm:ss` 格式字符串(不再接受毫秒时间戳);`pageSize` 上限为 20,超过会报错(简单模式为 `--limit`) - 催办必须两步串联:`ding-info` 仅返回被催办人 `userId`,不返回 robotCode/content;需再调用 `dws ding message send`,其中 `--robot-code` 优先使用环境变量 `$DINGTALK_DING_ROBOT_CODE`,若无则向用户确认;`--content` 由 agent 根据审批上下文撰写催办文案;**严禁跳过 `ding-info` 直接猜测 userId** - 催办文案建议格式:`"请尽快审批《{表单名}》(提交人:{发起人},提交时间:{时间})"` - `ding-info` 返回空或报错时,必须明确告知用户"无法获取该任务的被催办人信息"并停止 @@ -933,12 +990,6 @@ dws oa approval create-instance --request '{"processCode":"PROC-xxx","deptId":-1 | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws oa +list-cc` | read | 获取抄送当前用户的审批单列表 | -| `dws oa +list-executed` | read | 获取当前用户已经处理过的审批单列表 | -| `dws oa +list-forms` | read | 获取当前用户可见的审批表单列表 | -| `dws oa +list-pending` | read | 查询待我处理的审批(时间范围为 epoch 毫秒) | -| `dws oa +list-submitted` | read | 获取当前用户已发起的审批单列表 | -| `dws oa +my-initiated` | read | 列出我发起(提交)的审批单据 | | `dws oa +search-forms` | read | 按关键字模糊搜索当前用户可见的审批表单 | diff --git a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md index 79e7ae5c..ab985fd9 100644 --- a/skills/multi/dingtalk-misc/references/oa/oa-form-components.md +++ b/skills/multi/dingtalk-misc/references/oa/oa-form-components.md @@ -239,14 +239,14 @@ ### DDAttachment(附件控件) -> **[注意] 当前暂不支持通过 CLI 提交附件控件。** 附件控件的 value 需要包含 spaceId、fileName、fileSize、fileType 和 fileId 字段,这些字段需要通过调用钉盘的上传附件接口获取,CLI 尚未封装此流程。包含附件控件的审批模板请在钉钉客户端操作。 +> **[支持] 已支持通过 CLI 提交附件控件。** 采用两步流程:先用 `dws oa approval attachment upload --file ` 上传本地文件,获取 spaceId、fileName、fileSize、fileType、fileId;再将这些字段组装为 DDAttachment value(JSON 数组转义字符串)随 `create-instance` 提交。 | 属性 | 说明 | |------|------| | `componentName` | `DDAttachment` | | value 格式 | JSON 数组转义字符串,每个元素包含 spaceId、fileName、fileSize、fileType、fileId | | 示例(参考) | `"[{\"spaceId\":\"163xxx\",\"fileName\":\"2644.JPG\",\"fileSize\":\"333\",\"fileType\":\"jpg\",\"fileId\":\"643xxx\"}]"` | -| 约束 | **当前不支持通过 CLI 提交**;各字段需通过钉盘上传附件接口获取 | +| 约束 | **支持通过 CLI 提交**;先用 `dws oa approval attachment upload --file ` 获取 spaceId、fileName、fileSize、fileType、fileId,再组装为 value 随 `create-instance` 提交 | ### StarRatingField(评分控件) @@ -328,7 +328,6 @@ | 流水号 | `SeqNumberField` | 由系统自动生成 | | OCR 文本识别 | `OcrTextField` | 需要客户端 OCR 交互 | | OCR 身份证识别 | `OcrIdCardField` | 需要客户端 OCR 交互 | -| 附件控件 | `DDAttachment` | value 需要 spaceId、fileName、fileSize、fileType、fileId,须通过钉盘上传接口获取,CLI 尚未封装 | > **部分支持的控件:** `DDPhotoField`(图片控件)**支持通过 URL 直接提交**,但不支持本地文件上传(CLI 未封装钉盘 CDN 上传流程)。若用户只有本地文件,需告知在钉钉客户端补充。详见本文 [DDPhotoField](#ddphotofield图片控件) 章节。 diff --git a/skills/multi/dingtalk-misc/references/recruit.md b/skills/multi/dingtalk-misc/references/recruit.md new file mode 100644 index 00000000..2ed44b96 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/recruit.md @@ -0,0 +1,89 @@ +# 钉钉招聘 + +`dws recruit` 查询和创建钉钉招聘职位。当前公开命令只覆盖职位列表、职位详情和 +职位创建;候选人、面试、Offer、职位修改、开放或关闭尚未作为稳定命令发布。 + +所有命令都应加 `--format json`。组织、业务标识和当前操作人由登录身份及 MCP +Connector 注入,不要要求用户提供或猜测 `corpId`、`bizCode`、`opUserId`。 + +## 查询职位列表 + +```bash +dws recruit job list --format json +dws recruit job list --keyword "Java" --status open --size 20 --format json +dws recruit job list --job-ids JOB_ID_1,JOB_ID_2 --format json +``` + +可选筛选包括 `--job-ids`、`--required-edu`、`--status`、`--job-nature`、 +`--campus`、`--start-modified-time`、`--end-modified-time`、 +`--creator-user-ids`、`--keyword`、`--category`。状态接受: + +- `draft`:草稿 +- `open`:招聘中 +- `invalid`:已失效 +- `closed`:已关闭/完成 + +`--size` 默认 20,范围 1–100。首页不传 `--cursor`;响应 `hasMore=true` 时, +把 `nextCursor` 回填到下一次查询。 + +## 查询职位详情 + +先从列表结果取得真实 `jobId`,不得编造: + +```bash +dws recruit job get --job-id JOB_ID --format json +``` + +## 创建职位 + +创建是非幂等远端写入。先准备只包含职位对象的 UTF-8 JSON 文件,并用 +`--dry-run` 检查完整调用;向用户展示职位名称、性质、薪资、创建人和负责人(如有),取得 +明确确认后,交由 CLI 的确认流程执行实际创建。不要在存储示例中加入确认绕过参数。 + +`creatorUserId` 是必填字段,必须使用同一 profile 下当前操作者的真实 userId,不得 +猜测或复用其他组织的 userId。`ownerUserIds` 是可选的负责人 userId JSON 字符串数组; +提供负责人时同样必须使用真实通讯录结果。用户说“负责人是我”时先运行 +`dws contact user get-self --format json` 取得当前 userId;用户指定其他负责人时按 +通讯录 Skill 解析唯一 userId。未指定负责人时允许省略 `ownerUserIds`,不要默认选人。 + +```bash +dws recruit job create --from ./job.json --dry-run --format json +dws recruit job create --from ./job.json --format json +``` + +最小 `job.json`: + +```json +{ + "name": "Java 开发工程师", + "description": "负责服务端系统开发", + "jobNature": "FULL-TIME", + "requiredEdu": 6, + "minSalary": 20000, + "maxSalary": 35000, + "extData": { + "headCount": 1, + "fullTimeExtData": { + "salaryMonth": 12 + } + }, + "creatorUserId": "CURRENT_USER_ID", + "ownerUserIds": ["OWNER_USER_ID"] +} +``` + +必填字段为 `name`、`description`、`jobNature`、`requiredEdu`、`extData`、 +`creatorUserId`;`ownerUserIds` 可选。 +`jobNature` 当前固定为 `FULL-TIME`;`requiredEdu` 为 1–9 的整数(1小学、2初中、 +3高中、4中专、5大专、6本科、7硕士、8博士、9其他)。`minSalary` 与 +`maxSalary` 可选;两者同时提供时最低薪资不得高于最高薪资。 + +`extData.headCount` 范围 1–999;`extData.fullTimeExtData.salaryMonth` 范围 +12–24;最高工作年限不得小于最低工作年限。提供 `address` 时必须同时包含 +`name`、`detail`、`longitude`、`latitude`,经纬度只能来自地图选点或可信地址解析, +不得猜测。`source` 可省略,由 Connector 默认补充为 `manual`;不传 `category` 时 +Connector 会设置 `checkJobCategory=false`。 + +可选字段以当前 `dws recruit job create --help` 和 leaf Schema 为准。不要把 MCP +信封字段 `atsAddJobParam`、`corpId`、`bizCode` 或 `opUserId` 写进文件;CLI 与 +Connector 会负责包装和身份注入。 diff --git a/skills/multi/dingtalk-misc/references/report.md b/skills/multi/dingtalk-misc/references/report.md index 9ff940e9..cf73d9d5 100644 --- a/skills/multi/dingtalk-misc/references/report.md +++ b/skills/multi/dingtalk-misc/references/report.md @@ -449,8 +449,10 @@ dws report outbox list --cursor 0 --size 20 --format json | Shortcut | 风险 | 适用场景 | |---|---|---| -| `dws report +inbox-list` | read | 列出我收到的日报(按时间范围分页) | -| `dws report +outbox-list` | read | 列出我发出的日报(可选时间/模版名过滤) | +| `dws report +inbox-list` | read | 列出我收到的日志 | +| `dws report +outbox-list` | read | 列出我发出的日志 | +| `dws report +report-latest` | read | 读取我最近提交的一篇日志详情 | +| `dws report +template-search` | read | 按名称搜索可用日志模板 | ## 意图表 diff --git a/skills/multi/dingtalk-misc/references/sheet.md b/skills/multi/dingtalk-misc/references/sheet.md index c884f44a..1fb2d5d1 100644 --- a/skills/multi/dingtalk-misc/references/sheet.md +++ b/skills/multi/dingtalk-misc/references/sheet.md @@ -54,6 +54,7 @@ | 图片写入单元格 / 浮动图片 | `write-image` / `media-upload` + `create-float-image` | [sheet-media-image](sheet/sheet-media-image.md) | 用 `range update` 写图片 | | 条件高亮 / 标红 / 数据条 / 色阶 | `cond-format` | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 用静态 `set-style` 冒充条件格式 | | 单元格评论 / 批注 / @人讨论 | `comment list/create/reply/update/delete` | [sheet-comment](sheet/sheet-comment.md) | 把评论内容写进单元格值 | +| 查询当前 revision / 复核两个 revision 间的编辑 | `revision-get` / `changeset-get` | [sheet-revision-changeset](sheet/sheet-revision-changeset.md) | 把 revision 当历史快照 version,或把前向 change 当成当前最终值 | ## 触发必读 @@ -63,6 +64,7 @@ - 涉及合并、冻结、工作表增删改、最后非空边界:先读 [sheet-workbook](./sheet/sheet-workbook.md)。 - 涉及行列插删、隐藏、尺寸、移动、分组:先读 [sheet-dimension-operations](./sheet/sheet-dimension-operations.md)。 - 涉及多个写操作一次提交:先读 [sheet-batch-operations](./sheet/sheet-batch-operations.md)。 +- 涉及 revision、changeset、编辑审计、语义变更、撤销或状态重置:先读 [sheet-revision-changeset](./sheet/sheet-revision-changeset.md)。 ## Reference 索引 @@ -82,13 +84,14 @@ | [sheet-filter](sheet/sheet-filter.md) | 全局筛选。当用户说"筛选"、"过滤"、"只看某些行"(未说"筛选视图")时使用。禁止用"删除不符合条件的行"代替筛选。命令:`filter get`/`create`/`delete`/`update`/`clear-criteria`/`sort` | | [sheet-filter-view](sheet/sheet-filter-view.md) | 筛选视图(个人化,不影响协作者)。当用户明确说"筛选视图"时使用,与全局筛选相互独立。命令:`filter-view list`/`create`/`update`/`delete`/`info`/`update-criteria`/`delete-criteria`/`list-criteria`/`get-criteria` | | [sheet-conditional-format](sheet/sheet-conditional-format.md) | 条件格式规则。触发词:标红/标黄/高亮/突出/标记/数据条/色阶/颜色随数据变 → **强制**走条件格式,禁止 `range set-style` 静态样式替代。命令:`cond-format list`/`create`/`update`/`delete` | -| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令一站式,CLI 内部自动轮询,禁止 Agent 侧重试。命令:`export` | +| [sheet-export](sheet/sheet-export.md) | 导出表格为 xlsx。当用户说“导出”、“下载xlsx”、“存为Excel”时使用。单命令会自动等待结果,禁止 Agent 侧重复调用。命令:`export` | | [sheet-import](sheet/sheet-import.md) | 导入本地表格文件为在线电子表格。当用户说“导入表格”、“把xlsx传成在线表格”、“上传Excel变在线表格”时使用。Agent 入口为 `import create`,`import` 保留兼容;超时后只用 `import get` 续查,禁止重新创建或用 `drive upload` 代替。命令:`import create`/`import get` | | [sheet-chart](sheet/sheet-chart.md) | 浮动图表管理。当用户说“画图/数据可视化/趋势图/对比图/占比图/柱形图/折线图/饼图”时使用。禁止用本地脚本生成静态图片替代。命令:`chart list`/`create`/`update`/`delete` | | [sheet-pivot-table](sheet/sheet-pivot-table.md) | 透视表管理。当用户说“透视表/分组汇总/交叉分析/按X统计Y”时使用。禁止用公式拼汇总表替代。命令:`pivot-table list`/`create`/`update`/`delete` | | [sheet-comment](sheet/sheet-comment.md) | 单元格评论管理。当用户说“给单元格加评论/批注”、“看某格的评论”、“回复/更新/删除评论”、“@某人讨论这个数据”时使用。评论锚定单元格(`--sheet-id`+`--range`),禁止把评论写进单元格值。命令:`comment list`/`create`/`reply`/`update`/`delete` | | sheet-template | 表格模板管理。当用户说“用模板创建表格”、“搜索模板”、“模板列表”时使用。命令:`template list`/`template search`/`template apply` | -| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;版本号从 `version list` 获取。命令:`version save`/`version list`/`version revert` | +| [sheet-version](./sheet/sheet-version.md) | 表格历史版本管理。当用户说“保存版本/存个快照/看历史版本/版本列表/回滚到某个版本或精确 revision/恢复到之前的表格”时使用。回滚为危险操作,默认二次确认;通常从 `version list` 选目标,用户明确要求时也可使用同一工作簿中已确认且仍可恢复的 revision。命令:`version save`/`version list`/`version revert` | +| [sheet-revision-changeset](./sheet/sheet-revision-changeset.md) | 工作簿 revision 与语义化前向 changeset。当用户说“当前 revision/两个版本之间改了什么/复核编辑/回滚到了哪里”时使用。命令:`revision-get`/`changeset-get` | ## 全局硬约束 @@ -99,12 +102,12 @@ 5. **最后非空坐标只用 A1 语义**:`sheet info` 通过 `nonEmptyRange` 返回 A1/UI 边界;优先使用 `nonEmptyRange.range`,需要追加行/列时使用 `nonEmptyRange.lastRow` / `nonEmptyRange.lastColumn`。不要使用旧的 0-based 字段 `lastNonEmptyRow` / `lastNonEmptyColumn` 6. **`range update` 维度校验**:`--values` 行列数必须与 `--range` 完全一致;只接 `--values` 一个数据参数,cell `type` 仅支持 `text` / `richText`;整格超链接通过 cell-level `hyperlink` 表达,富文本片段链接才使用 `richText.texts[].type="link"` 7. **dataValidation 三语义**:不传 `dataValidation` 字段=保留原 DV;`dataValidation:{type:"none"}`=显式清除;`dataValidation:{type:"dropdown"/"checkbox",...}`=覆盖。`{}` 跳过亦保留原 DV -8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。Agent 调用不要用 `hyperlink:null`,避免网关/Schema 过滤 null 字段 +8. **hyperlink 三语义**:不传 `hyperlink` 字段=保留原整格超链接;`hyperlink:{type:"none"}`=显式清除;`hyperlink:{type:"path"/"sheet"/"range",link,...}`=覆盖。不要用 `hyperlink:null`,该写法不能可靠表达清除 9. **样式写法**:cell-level 样式用 `cellStyles` 或 `range set-style`;richText 片段级样式才用子项 `style`。不要在 `type:"text"` 顶层使用旧 `style` 字段 10. **用专用命令不用组合模拟**:搜索→`find`、替换→`replace`、清空→`range clear`、排序→`range sort`、填充→`range fill`、复制区域→`range copy-to`、移动区域→`range move-to`、移动行列→`move-dimension` 11. **大批量 CSV 值/公式用 `csv-put`**(>5 行或 >20 单元格,且无需富格式),不用 `range update`;需要 dataframe/table 语义(列名、dtypes、formats、跨 sheet specs)时用 `table-get` / `table-put` 12. **单元格图片用 `write-image`**(`range update` 不支持图片参数) -13. **`export` / `import create` 禁止自行轮询**(CLI 内部已完成渐进式退避,最多 30 次约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 +13. **`export` / `import create` 禁止自行轮询**(命令会自动等待结果,最长约 5 分钟);导入超时只按返回的 `next_command` 调用 `import get`,导出失败或超时直接转述,不自动重调 14. **单次调用上限**:`range update` / `set-style` 行数 ≤ 1000,单元格总数建议 ≤ 5000(硬限 30000) 15. **大整数精度保护**:超过 `9007199254740991` 的整数和长数字标识符必须按文本写入;`table-put` 中不要使用 JSON number 或 `int64` / `uint64` dtype,改用字符串值 + `object` dtype + `formats`/`cellStyles` 的 `@` 16. **批量写操作推荐用 `batch-update`**:对多个区域重复调用同一写入工具时,推荐合并为单次原子请求(详见 [sheet-batch-operations](./sheet/sheet-batch-operations.md));逐个调用非原子,中途失败会留下半成品。结构化 table 例外,`table-put` 不进 `batch-update` @@ -115,9 +118,10 @@ - **公式**:支持通过 `range update` 写入、通过 `range read --value-render-option formula` 回读公式文本、通过 `formula-verify` 按错误类型聚合扫描、通过 `raw_value` / `formatted_value` 回读计算结果。只有 `formula-verify` 返回 `status=success`、`hasMore=false`、`totalErrors=0` 时,才能声称本次目标范围未发现公式错误;这仍不等价于业务数值一定正确。 - **视觉规范**:当前不维护独立的表格视觉方案文档;样式、条件格式、图表分别按对应子文档执行。 - **结构化 table**:`table-get` / `table-put` 是 table/dataframe 语义入口,不嵌入 `batch-update`;需要原子组合时只组合已支持的单元格/结构写操作。 -- **历史版本**:支持通过 `version save`/`list`/`revert` 手动保存快照、查看版本列表、回滚到指定版本(底层复用 doc 域版本能力);回滚为危险操作,需二次确认。 +- **历史版本与 revision**:`version save`/`list` 管理选定的历史版本;`version revert` 通常回滚到列表项,也可在用户明确要求时回滚到同一工作簿中已确认且仍可恢复的精确 revision。列表项 `version` 可作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision;禁止猜测未确认的 revision。 - **单元格评论**:支持通过 `comment create`/`list`/`reply`/`update`/`delete` 管理锚定在 `--sheet-id` + `--range` 的评论;评论与单元格值相互独立,详见 [sheet-comment](sheet/sheet-comment.md)。 -- **未暴露或未确认能力**:迷你图、历史 changeset 等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 +- **changeset 边界**:返回的是 `EDIT`/`UNDO`/`STATE_RESET` 事件及提交时的前向语义 change,不是统一的单元格 `oldValue/newValue` diff,也不保证代表当前最终状态;需要最终值必须额外回读。 +- **未暴露或未确认能力**:迷你图等能力未在本入口承诺;只有出现稳定 DWS 命令和可回读语义后再补充。 ## URL 粘贴场景 @@ -144,7 +148,7 @@ dws sheet import create --file ./report.xls --folder-token --name dws sheet import create --file ./data.xls --workspace ``` - 支持 `.xlsx` / `.xls`,固定产出**在线电子表格**;只新建、不覆盖已有表格 -- CLI 内部自动完成「创建会话→上传→确认→渐进式退避轮询」(最多 30 次约 5 分钟),**禁止 Agent 侧再实现轮询或重试** +- 命令会自动上传、确认并等待结果(最长约 5 分钟),**禁止 Agent 侧再实现轮询或重试** - 导入结果返回 `documentUrl` / `nodeId`,`nodeId` 可直接作为后续 `sheet` 命令的 `--node` - **禁止用 `dws drive upload --convert` 代替**:drive upload 只上传文件、不在表格导入闭环内,产出不保证为可编辑在线电子表格 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md index 13a614f4..b8d58280 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-comment.md @@ -12,7 +12,7 @@ - 仅支持钉钉在线电子表格(`extension=axls`)。`xlsx` / `xls` / `csv` 等本地表格不支持评论。 - 评论锚定在**单元格位置**:`create` / `list` 通过 `--sheet-id`(工作表 ID 或名称)+ `--range`(单元格坐标)定位;`reply` / `update` / `delete` 通过 `--comment-key` 操作,不依赖单元格位置。 - `create` / `list` 通过单元格位置定位;`reply` / `update` / `delete` 通过前一步返回的 `commentKey` 定位评论线程,不需要重新传单元格位置。 -- 评论正文与单元格位置由服务端关联。Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不解析或拼接内部关联字段。 +- Agent 只使用命令返回的 `commentKey` 继续回复、更新或删除,不自行构造评论标识。 --- diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md index 05b5f0c0..07c32d65 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-dropdown.md @@ -66,7 +66,7 @@ Flags: 查询指定范围内的下拉列表配置信息。 - **用途**:查看单元格已设置的下拉列表选项和配置。 - **场景**:在修改下拉列表前先查询现有配置;确认下拉列表是否设置成功。 -- **返回**:`dataValidations` 数组由底层按配置聚合。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 +- **返回**:`dataValidations` 按相同配置分组。Inline 组返回 `sourceType:"inline"`、`conditionValues`、`ranges` 和 `options`;SourceRange 组始终返回 `sourceType:"sourceRange"`、`sourceRangeStatus:"valid"/"invalid"`、`enableMultiSelect` 和 `ranges`,仅在 `sourceRangeStatus:"valid"` 时返回 `sourceRange:{sheetId,a1Notation}`。`invalid` 时仍保留配置组,但省略 `sourceRange`,不得依赖旧坐标修复。SourceRange 不会展开候选值,因此不返回 `conditionValues`、`options` 或颜色。范围内无下拉列表时 `hasDropdown` 为 false。 ### 删除下拉列表 ``` @@ -99,5 +99,5 @@ Flags: - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) - `set-dropdown` 的 Inline 模式使用 `--options`,每个元素包含 `value`(必填)和 `color`(可选,`#RRGGBB`);SourceRange 模式使用 `--source-sheet-id` + `--source-range`。两种模式均可用 `--multi-select`,并会覆盖目标范围已有下拉 - SourceRange 在已验证的重命名、引用前插入行/列、删除引用前行的场景会自动调整;已验证的 `move-dimension` 会使其变为 `invalid`。其他未覆盖删除/移动场景后先回读,仅 `invalid` 时重新选源写入;颜色写入暂不支持 -- `get-dropdown` 查询指定范围内的下拉配置,聚合发生在底层服务。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 +- `get-dropdown` 查询指定范围内的下拉配置,并按相同配置分组。SourceRange 即使无效也保留一组并以 `sourceRangeStatus:"invalid"` 表示,但省略 `sourceRange`,不回退展开候选值 - `delete-dropdown` 删除指定范围内的下拉列表配置,单元格恢复为普通文本格式。已填写的值不会被清除。目标范围不存在下拉列表时操作仍返回成功 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md index ba0880c6..8c067aff 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-export.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-export.md @@ -5,11 +5,11 @@ ### 导出 用户说"导出/下载xlsx/存为Excel/存成表格文件/把表格变成xlsx/导出表格/下载表格/导出为 excel": -- 导出表格 → `export`(单命令一站式,内部自动完成提交、轮询、可选下载) +- 导出表格 → `export`(单命令会自动等待完成,并可选下载) - 仅需传 `--node`,可选 `--output` 指定本地文件/目录(不传则返回 downloadUrl) - 需要落盘到本地 → `dws sheet export --node --output `,命令自动下载 xlsx -- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出,必须使用 `export` 命令(服务端原子导出,保留格式/合并/公式等属性) -- 禁止在 AI Agent 侧实现轮询或重试,CLI 内部已按渐进式退避策略完成(最多 30 次约 5 分钟) +- 禁止用 `range read` 全量读取后自行拼接 xlsx 来模拟导出;必须使用 `export`,才能保留格式、合并和公式等属性 +- 禁止在 AI Agent 侧实现轮询或重试;命令会自动等待结果,最长约 5 分钟 用户说"导出 CSV/存成 csv/导出这个工作表为 csv": - 导出单个工作表为纯 CSV → `export-csv`(**同步**,不走异步任务;与 `export` 是两条独立命令) @@ -38,26 +38,14 @@ Flags: --output string 本地保存路径(可选,支持文件路径或目录) ``` -将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令内部自动完成「提交任务 → 渐进式退避轮询 → (可选)下载文件」全流程,AI Agent 无需自行拆分步骤或实现轮询。 - -**内部流程**: -1. 调 `submit_export_job` 获取 `jobId` -2. 按渐进式退避策略轮询 `query_export_job` 直至任务终态或超时 -3. 任务成功后取得 `downloadUrl`;若指定了 `--output`,自动 HTTP GET 下载 xlsx 到本地文件 - -**内置轮询策略(CLI 内实现,无需关心)**: -- 第 1~5 次:每次间隔 2 秒 -- 第 6~10 次:每次间隔 5 秒 -- 第 11~20 次:每次间隔 10 秒 -- 第 21~30 次:每次间隔 15 秒 -- **硬上限:最多轮询 30 次(约 5 分钟)**,超时后命令返回错误 +将钉钉在线电子表格导出为 Office xlsx 格式。**单命令一站式**:命令会自动等待导出完成,并在指定 `--output` 时保存到本地。AI Agent 无需拆分步骤或自行轮询;最长等待约 5 分钟,超时后按命令错误处理。 **命令返回**: - `--output` 未指定:进度日志 + 末尾输出 `jobId` 和 `downloadUrl`(链接有时效性,请尽快下载) - `--output` 指定为文件路径:下载到该路径并输出 `导出完成: ` - `--output` 指定为已存在目录:自动从 `downloadUrl` 推断文件名并保存到该目录下 -**失败处理(命令内部已处理,Agent 仅需转述)**: +**失败处理**: - 导出任务返回 `FAILED`:命令立即返回错误并附带失败原因,**禁止自动重试 `dws sheet export`**,告知用户稍后再试 - 轮询 30 次仍 `PROCESSING`:命令返回超时错误,告知用户稍后再试 @@ -111,7 +99,7 @@ dws sheet export-csv --node --sheet-id --allow-truncated -- ```bash # ── 工作流 12: 导出表格为 xlsx(单命令一站式)── -# 场景 A:仅获取下载链接(命令内部自动完成提交+轮询,最终返回 downloadUrl) +# 场景 A:仅获取下载链接(命令自动等待完成并返回 downloadUrl) dws sheet export --node --format json # 传入 URL 也可: # dws sheet export --node "https://alidocs.dingtalk.com/i/nodes/" --format json @@ -122,7 +110,7 @@ dws sheet export --node --output ./report.xlsx # 场景 C:下载到目录,自动按链接推断文件名 dws sheet export --node --output ./ -# 禁止在 Agent 侧实现任何轮询或重试,CLI 内部已按 2s/5s/10s/15s 渐进式退避自动完成(最多 30 次)。 +# 禁止在 Agent 侧实现任何轮询或重试;命令会自动等待结果。 # 若命令返回失败或超时,直接告知用户稍后再试,不要自动重调 dws sheet export。 ``` @@ -130,18 +118,18 @@ dws sheet export --node --output ./ | 操作 | 从返回中提取 | 用于 | |------|-------------|------| -| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地。命令内部已完成轮询,不要再调用其他 export 相关命令 | +| `export` | `downloadUrl`(未指定 --output)/ `outputPath`(指定 --output) | 直接下发给用户或告知文件已保存到本地;不要再调用其他 export 相关命令 | | `export` 超时中断 | 错误信息 | 直接报告失败或超时;当前没有独立续查命令,不自动重新提交导出 | | `export-csv` | CSV 正文(未指定 --output,走 stdout)/ `导出完成: `(指定 --output) | 直接把 CSV 交给下游处理,或告知文件已保存到本地。命令是同步的,无任务/轮询概念 | ## 注意事项 - ★ `export` 仅支持钉钉在线电子表格(axls)→ xlsx;传入钉钉文字文档会报 `invalidRequest.document.typeIllegal` -- ★ `export` 为单命令一站式,CLI 内部已自动完成「提交 → 渐进式退避轮询 → 可选下载」,**Agent 不得在外部实现轮询或重试**;命令返回成功后不再调用其他 export 相关命令 +- ★ `export` 为单命令一站式,会自动等待结果并可选下载;**Agent 不得自行轮询或重试**,命令返回成功后不再调用其他 export 相关命令 - `export` 内置轮询策略:1~5 次间隔 2s、6~10 次间隔 5s、11~20 次间隔 10s、21~30 次间隔 15s,硬上限 30 次(约 5 分钟);超时后命令返回错误,告知用户稍后再试即可 - ★ `export` 命令返回失败或超时时,**禁止自动重调 `dws sheet export`**;直接告知用户导出失败并建议稍后再试 - `export` 未指定 `--output` 时,返回的 `downloadUrl` 具有时效性,获取后请尽快下载;若用户需要本地文件,优先直接传 `--output` 让 CLI 代为下载 - `export` 的 `--output` 可为文件路径或已存在目录;为目录时自动从 `downloadUrl` 推断文件名,为文件路径时直接按该路径保存 -- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出(服务端导出会保留格式/合并/公式等完整属性) +- 用户要求"导出表格/下载 xlsx"时,必须使用 `export` 单命令,禁止用 `range read` 读全量数据后自行拼 xlsx 模拟导出;`export` 会保留格式、合并、公式等属性 - `export-csv` 与 `export` 是两条独立命令:`export-csv` 同步导出**单个**工作表的纯值 CSV,不保留样式/合并/公式;要整篇工作簿或完整属性一律用 `export` - ★ `export-csv` 遇到数据超出单次读取上限时默认报错、既不输出也不写文件;优先用 `--range` 分块导出,只有用户明确接受不完整数据时才加 `--allow-truncated` diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md b/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md index 0e65607b..90bfe695 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-filter.md @@ -173,7 +173,7 @@ Flags: - `filter update` 前置:工作表必须已创建筛选 - `filter clear-criteria` 仅清除指定列的条件,不删除整个筛选。指定列无条件时不报错(幂等) - `filter sort` 会实际改变数据行的物理顺序,不可撤销。前置:工作表必须已创建筛选 -- ★ **筛选操作规范**(参照飞书 core-operations): +- ★ **筛选操作规范**: - 当用户要求"筛选/只看/仅保留 X"时,**必须**通过 `filter create` / `filter update` 创建真实的筛选器。**禁止**用"删除不符合条件的行"或"新建工作表只放符合条件的行"来代替 - 创建/更新筛选后**必须** `filter get` 回读验证配置正确 - 更新已有筛选前先 `filter get` 读取当前配置,确认目标存在且了解现有条件后再操作 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md index d50b27b4..b301e4bf 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-import.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-import.md @@ -6,7 +6,7 @@ - 支持本地 `xlsx` / `xls`,文件上限 20MB - `--folder-token` 与 `--workspace` 至少提供一个 -- CLI 内部完成创建会话、上传文件、确认转换和轮询,Agent 不要自行拆分或重试 +- `import create` 会自动上传文件、确认转换并等待结果,Agent 不要自行拆分或重试 - `drive upload` 只上传二进制文件,不会转换为可编辑的在线表格,不能替代本命令 ## 命令 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md index 06aba4b7..d3e236f0 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-media-image.md @@ -228,8 +228,8 @@ dws sheet write-image --node --sheet-id --range B2:B2 --fil ## 注意事项 - ★ **`--sheet-id` 获取规范(强制)**:`sheetId` 未知时必须先通过 `dws sheet list --node --format json` 查询,禁止凭空编造(如臆测为 `Sheet1`、`sheet1`、`0`、`default` 等) -- `media-upload` 是两步自动完成的流程 (获取附件上传凭证 → OSS 上传),无需手动分步操作 -- `write-image` 是三步自动完成的流程 (获取附件上传凭证 → OSS 上传 → 写入图片到单元格),无需手动分步操作 +- `media-upload` 会自动完成图片上传并返回后续命令需要的资源信息,无需手动拆分步骤 +- `write-image` 会自动完成图片上传并写入目标单元格,无需手动拆分步骤 - ★ 向表格单元格中写入图片必须使用 `write-image`,禁止使用 `range update`。`range update` 不支持图片对象 - `write-image` 与 `media-upload` 的区别:`media-upload` 仅上传附件到表格获取 resourceId;`write-image` 在上传后还会将图片写入指定单元格 - `create-float-image` 创建浮动图片前必须先通过 `media-upload` 上传图片获取 `resourceUrl`,再将其作为 `--src` 传入。`--src` 的格式为 `/core/api/resources/img/...`,不能直接传外部 URL diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md index 6b8f93d2..2e451972 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-pivot-table.md @@ -191,7 +191,7 @@ Flags: - 示例:`'Sheet1'!A1:D100`、`'销售数据'!A1:F500` - 必须包含表头行(通常从第 1 行开始) - sheet 名称用单引号包裹(含空格或特殊字符时必须) -- `source` 直接使用上述 A1 表示法;不要自行改写成内部对象结构 +- `source` 直接使用上述 A1 表示法,不要改写成其他结构 ## 高级功能示例 @@ -210,7 +210,7 @@ dws sheet pivot-table create --node \ }' ``` -> collapse/show_data_as 为可选透传字段,CLI 和 MCP 层不做额外校验,直接传递给底层引擎处理。 +> `collapse` / `show_data_as` 为可选字段;仅使用命令文档列出的取值,并按命令返回处理无效配置。 ## 核心工作流 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md index f8008d0e..89fef5a2 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-read-data.md @@ -54,7 +54,7 @@ Flags: - `rowIndices` — 行号映射数组(如 `[1,2,3]`) - `hasMore` — 完整目标范围是否还有未返回数据。`true` 是部分成功,不能宣称已读完 - `truncationReasons` — 部分返回原因。`max_cells` 表示命中单次 30,000 单元格上限;`max_chars` 表示命中 CSV 字符上限,两者可同时出现 -- `resolvedRange` — 仅在未传 `--range` 时返回,表示底层解析出的完整目标范围 +- `resolvedRange` — 仅在未传 `--range` 时返回,表示本次读取所对应的完整目标范围 - `returnedRange` — 本次实际完整返回的精确范围;它不是服务端续读游标 `csv-get` 不返回合并单元格结构。若 CSV 中出现合并区域的非左上角单元格为空,不能据此判断该区域"无内容";需要先用 `dws sheet info --node --sheet-id --format json` 读取 `mergedRanges`,再结合左上角单元格理解合并区域语义。 @@ -161,7 +161,7 @@ Flags: | `dropdown` Inline | `enableMultiSelect: boolean` | 是否允许多选 | | `dropdown` SourceRange | `sourceType: "sourceRange"` | 区域来源模式 | | `dropdown` SourceRange | `sourceRange: {sheetId, a1Notation}` | 仅 `sourceRangeStatus:"valid"` 时返回的来源工作表和规范化 A1 区域;`invalid` 时省略 | -| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 底层引用是否可解析;无效引用仍返回配置,但不返回 `sourceRange` | +| `dropdown` SourceRange | `sourceRangeStatus: "valid" / "invalid"` | 来源引用是否有效;无效引用仍返回配置,但不返回 `sourceRange` | | `checkbox` | `checked: boolean` | 当前勾选状态 | SourceRange 模式不动态拉取或展开来源区域的候选值,因此不返回 `options` 或颜色。`invalid` 时只能依赖 `sourceRangeStatus` 判定需要重新选源,不得假定仍能从 `sourceRange` 取回旧坐标。 @@ -270,6 +270,7 @@ dws sheet range read --node --sheet-id --range "A1:D10" --f - ★ `csv-get` 和 `range read` 都必须检查 `hasMore`;`true` 时结合目标范围与 `returnedRange` 显式分批,CLI 不会自动续读 - ★ `forbidden.document.sizeOverLimit` 是工作簿装载失败,不是 30,000 单元格部分成功;缩小 `--range` 不能修复 - ★ `csv-get` / `range read` / `range get` 不返回合并单元格结构;查看合并范围必须用 `sheet info` 的 `mergedRanges` +- ★ 保护区域权限:`csv-get`、`table-get`、`range read`、`formula-verify` 等命令会检查本次实际读取范围。若返回权限错误,说明当前身份无法确认具有该范围的完整读取权限;不要解释为空数据或反复重试,应申请对目标内容的直接访问权限或请管理员执行。仅当任务本来就不需要受保护区域时,才可缩小 `--range` 避开无关区域,禁止拆分范围绕过权限。 - `table-get` 返回 table/dataframe 结构,适合需要 `columns` / `data` / `dtypes` / `formats` 的场景;它不返回 per-cell 元数据,也不替代 `range read` - ★ 大整数和长数字标识符回读校验:精确 ID 应是字符串 / `object`;若看到 `float64`,说明它已经按数值路径处理,超过 `9007199254740991` 时可能已丢精度 - `range read` 遇到超时或响应过慢时,应缩小 `--range` 查询范围,**单次读取的单元格数量建议控制在 5000 个以内**;数据量较大时通过 `info` 的 `nonEmptyRange.range` 获取 A1 边界后分批读取,避免不传 `--range` 直接读取整个大工作表 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md b/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md new file mode 100644 index 00000000..13e5b662 --- /dev/null +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-revision-changeset.md @@ -0,0 +1,306 @@ +# 工作簿 revision 与 changeset + +## 何时使用 + +当用户要知道在线电子表格当前 revision,或要复核两个 revision 之间提交了哪些变更、是否发生撤销或整表状态替换时使用。 + +- 当前 revision → `dws sheet revision-get` +- revision 区间内的变更 → `dws sheet changeset-get` +- 可保存、展示或回滚的历史版本 → [sheet-version](./sheet-version.md) +- 当前单元格值、公式或工作簿结构 → 使用对应 read/list/get 命令 + +两条命令都是工作簿级能力,只接受 `--node`,不接受 `--sheet-id`。`--node` 可传在线电子表格文档 ID 或完整 URL;`spreadsheetv2` URL 必须原样传入。 + +revision 表示工作簿的编辑进度;历史版本是选定的保存或回滚点。`version list` 返回的 `version` 可以作为 changeset 查询锚点,但相邻历史版本之间可能包含多个 revision。 + +当用户明确要求恢复到某个精确 revision 时,可以把已确认属于同一工作簿的 revision 传给 `version revert --version`,即使它没有出现在 `version list` 中。默认仍应从版本列表选择稳定的历史版本;未列入列表的 revision 只有在服务端仍可恢复时才会成功,禁止猜测或自动改用相邻 revision。具体安全流程见 [sheet-version](./sheet-version.md)。 + +## 获取当前 revision + +```bash +dws sheet revision-get --node --format json +``` + +成功时从统一输出的 `data.revision` 读取当前 revision: + +```json +{ + "ok": true, + "outcome": "success", + "data": { + "success": true, + "logId": "trace-id", + "revision": 142 + } +} +``` + +- `revision=0` 是合法的空工作簿基线。 +- revision 不是工作表数量,也不是历史版本列表序号。 +- `logId` 仅用于失败反馈和问题排查,不是 revision。 + +## 获取 revision 区间 changeset + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --end-revision 140 \ + --format json +``` + +也可以省略 `--end-revision`,查询到本次请求开始时的最新 revision: + +```bash +dws sheet changeset-get \ + --node \ + --start-revision 120 \ + --format json +``` + +参数规则: + +- `--start-revision` 必填,必须为非负整数;`0` 合法。 +- `--end-revision` 可选;提供时必须大于或等于 start。 +- 查询区间固定为 `(startRevision, endRevision]`:不包含 start,包含 end。 +- `startRevision == endRevision` 合法,返回空 `changesets`。 +- 单次最多查询 20 个 revision,即 `endRevision - startRevision <= 20`;更大区间必须连续分段查询。 +- 省略 end 时,本次响应会固定一个 `endRevision`;查询期间产生的新 revision 不进入本次结果。 +- 无法完整取得所请求区间时整次失败,不会把缺失的 revision 当作成功结果。 + +成功后直接读取统一输出中的 `data.changesets` 数组。DWS 已把它转换为可遍历的 JSON 对象,不需要额外解析。 + +## 先看区间摘要 + +`data.summary` 用于快速判断本次结果是否完整以及哪些工作表受到影响: + +| 字段 | 含义 | +|------|------| +| `changeCount` | `changes[]` 中变更对象的总数 | +| `completeChangeCount` | 详情完整的变更数量 | +| `partialChangeCount` | 只返回部分可信详情的变更数量 | +| `unsupportedChangeCount` | 当前无法解释的变更数量 | +| `containsStateReset` | 区间内是否发生整表状态替换 | +| `containsIncompleteChanges` | 是否存在不完整或无法解释的详情 | +| `affectedSheets` | 受影响工作表及其 A1 范围摘要 | + +`affectedSheets[]` 常用字段: + +| 字段 | 含义 | +|------|------| +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称;缺失时不要从 ID 猜测 | +| `ranges` | 去重后的 1-based A1 范围;工作表级变更可为空数组,整行/整列可表示为 `1:3` / `A:C` | + +摘要用于定位,不是完整变更副本。要回答改了什么,仍需遍历 `changesets[].changes[]`。 + +## 读取 `changesets[]` + +每个 changeset 对应一个 revision: + +| 字段 | 含义 | 使用规则 | +|------|------|----------| +| `revision` | 该事件对应的 revision | 按升序解释 | +| `createTime` | 事件创建时间 | 时间仅用于说明,顺序以 revision 为准 | +| `isSelfEdit` | 是否由当前请求用户提交 | `false` 不能用于识别具体编辑者,也不能判断是否由人工或自动化产生 | +| `eventType` | `EDIT`、`UNDO` 或 `STATE_RESET` | 先按事件类型分流 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | 决定能否把详情当作完整描述 | +| `changes` | 本 revision 的变更数组 | `STATE_RESET` 时为空数组 | +| `reset` | 整表状态替换信息 | 只在 `STATE_RESET` 时读取 | + +事件类型: + +- `EDIT`:一次普通编辑提交产生的效果。 +- `UNDO`:一次撤销提交产生的效果。只描述这次撤销实际改变了什么,不要反推被撤销操作的完整原貌。 +- `STATE_RESET`:工作簿整体状态被替换。不要跨过该事件拼接推导完整状态;需要重新读取工作簿。 + +changeset 描述的是该 revision 提交的效果,不是统一的变更前/变更后快照,也不保证仍是当前最终状态。 + +## 读取 `reset` + +| 字段 | 含义 | +|------|------| +| `type` | 状态替换类型:`ROLLBACK`、`OVERWRITE`、`UPGRADE`、`TEMPLATE`、`PRETTIFY` 或 `UNKNOWN_RESET` | +| `targetRevision` | 可确认时的目标 revision;`0` 是合法空基线 | +| `targetStatus` | `KNOWN` 表示目标已返回;`NOT_APPLICABLE` 表示该事件没有目标 revision;`UNAVAILABLE` 表示目标无法确认 | + +当 `targetStatus=UNAVAILABLE` 时,禁止把目标猜成 `revision-1`。即使目标 revision 已知,也要通过读取命令确认需要的当前内容。 + +回滚完成后,可用回滚前后的 revision 查询 changeset:若返回 `STATE_RESET`、`reset.type=ROLLBACK` 且 `reset.targetRevision` 等于请求目标,表示回滚事件指向了该 revision;工作簿内容是否符合预期仍需独立回读。 + +## 读取 `changes[]` + +每个 change 都按以下顺序读取: + +1. `type`:发生了哪类变更。 +2. `targets`:变更作用在哪个工作簿、工作表或范围。 +3. `detailsStatus`:详情是否完整。 +4. `details`:本次变更的可用详情。 +5. `omissions`:哪些详情未能提供。 + +公共字段: + +| 字段 | 含义 | +|------|------| +| `type` | 语义变更类型,见下方字典 | +| `targets` | 工作簿、工作表或 A1 范围定位数组 | +| `details` | 与 `type` 对应的变更详情;无详情时为空对象 | +| `detailsStatus` | `COMPLETE`、`PARTIAL` 或 `UNAVAILABLE` | +| `omissions` | 未提供详情的原因;没有省略时为空数组 | + +`detailsStatus` 的处理规则: + +- `COMPLETE`:已完整解释本类型可提供的本次变更详情,但不代表包含旧值或当前值。 +- `PARTIAL`:已返回字段可信,同时存在缺失;回答时要明确“仅确认到这些内容”。 +- `UNAVAILABLE`:不能可靠说明具体详情;只报告事件、类型和可信位置,不猜内容。 + +`omissions[].fields` 可指出受影响的详情字段。`omissions[].code` 只用于判断存在何种缺口;面向用户时翻译成“部分详情未提供”“公式文本不可用”“位置无法确认”等自然语言,不需要展示内部原因码。 + +## 读取 `targets[]` + +| 字段 | 含义 | +|------|------| +| `scope` | `WORKBOOK`、`SHEET` 或 `RANGE` | +| `sheetId` | 工作表 ID | +| `sheetName` | 可用时的工作表名称 | +| `sheetNameSource` | 名称是变更时名称、当前名称或无法确认 | +| `a1Range` | 1-based A1 范围 | +| `role` | `AFFECTED`、`SOURCE` 或 `DESTINATION`;缺失时按 `AFFECTED` 处理 | + +- `SOURCE` 是读取或复制来源,不表示该位置被修改。 +- `DESTINATION` 是写入目标,应算作受影响位置。 +- `WORKBOOK` 级 target 通常没有工作表或 A1 范围,不要补造。 +- `sheetName` 缺失或来源无法确认时,优先使用 `sheetId` 定位并按需调用 `sheet list`。 + +## 变更类型字典 + +### 行列与工作表 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `ROWS_INSERTED` / `COLUMNS_INSERTED` | 插入行或列 | `startNumber`、`count`、`styleSource?` | +| `ROWS_DELETED` / `COLUMNS_DELETED` | 删除行或列 | `startNumber`、`count` | +| `ROWS_UPDATED` / `COLUMNS_UPDATED` | 更新行列属性 | `startNumber`、`count`、`properties?`、`relativeChanges?`、`replace?` | +| `SHEET_CREATED` | 创建工作表 | `position?`、`properties?`、`copiedFromSheetId?` | +| `SHEET_DELETED` | 删除工作表 | 通常由 target 定位 | +| `SHEET_UPDATED` | 更新工作表名称、位置或属性 | `position?`、`properties?` | +| `CUSTOM_TAB_ADDED` / `CUSTOM_TAB_DELETED` / `CUSTOM_TAB_UPDATED` | 新增、删除或更新自定义 Tab | `tabId`、`position?`、`properties?` | + +`startNumber` 和 `position` 为 1-based。行列 `properties` 常见字段包括 `size`、`hidden`、`sticky`、`cellType`;工作表属性常见字段包括 `name`、`hidden`、`tabColor`、冻结行列数和默认行列尺寸。只解释实际返回的字段。 + +### 单元格与范围 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `CELLS_INSERTED` / `CELLS_DELETED` | 在 target 内插入或删除单元格并平移 | `axis` | +| `RANGE_PASTED` | 将来源内容粘贴到目标范围 | `pasteMode`、`isCut`、`step?`、`includedParts?`、`contentPattern?` | +| `RANGE_AUTOFILLED` | 自动填充目标范围 | `fillMode`、`copyStyle`、`step?` | +| `RANGE_CLEARED` | 清除范围内指定内容 | `clearParts?`、`preservedCellTypes?` | +| `RANGE_CONTENT_SET` | 对整个范围写入相同内容或公式 | `cell?`、`replaceMode?` | +| `RANGE_BORDER_SET` | 设置范围边框 | `border?`、`borderSides?` | +| `RANGE_STYLE_SET` | 设置范围样式 | `style`、`replace?` | +| `RANGE_TAG_SET` | 设置或清除范围标签 | `tag?`、`replace?` | +| `RANGE_SORTED` | 对范围排序 | `sortByRange?`、`beforeOrder?`、`afterOrder?`、`indexesAreRelative` | +| `CELLS_CONTENT_SET` | 按相对位置分别修改内容 | `relativeChanges`、`replaceMode?` | +| `CELLS_STYLE_SET` | 按相对位置分别修改样式 | `relativeChanges?`、`styles?`、`styleMode?`、`replace?` | +| `CELLS_TAG_SET` | 按相对位置分别修改标签 | `relativeChanges`、`replace?` | + +粘贴和自动填充的 `SOURCE` target 是来源,`DESTINATION` target 是写入位置。自动填充的 `DESTINATION` 可能是包含 `SOURCE` 的完整填充跨度;重叠部分不表示来源值发生了变化。描述新增填充区域时使用 `DESTINATION` 减去 `SOURCE`,核对最终结果时仍回读完整 `DESTINATION`。`step.rows` / `step.columns` 表示重复模式的大小。`contentPattern.cells[]` 使用 `rowOffset` / `columnOffset` 表示相对模式左上角的位置,并可返回写入的 `value`、`formula`、`cellType` 或 `link`。 + +`RANGE_SORTED.beforeOrder` / `afterOrder` 只列实际换位的行或列,未移动的位置会省略;数组短于 target 的行列数不代表详情缺失。`indexesAreRelative` 决定这些索引是相对 target 还是工作表绝对索引。 + +`relativeChanges[]` 中的 `rowOffset` / `columnOffset` 都是相对 target 左上角的 0-based 偏移,不是工作表绝对行列号;必须与 target 起点相加后再换算成 A1。 + +### 分组、数据验证和结构对象 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `DIMENSION_GROUP_ADDED` / `DIMENSION_GROUP_REMOVED` / `DIMENSION_GROUP_UPDATED` | 新增、删除或更新行列分组 | `axis`、`startNumber`、`count`、`depth?`、`collapsed?` | +| `DATA_VALIDATION_SET` | 设置数据验证规则 | `ruleId?`、`dataValidation` | +| `DATA_VALIDATION_CLEARED` | 清除数据验证规则 | `ruleId?` | +| `CELLS_MERGED` / `CELLS_UNMERGED` | 合并或取消合并单元格 | `featureId?`、`effectless?` | +| `NAMED_RANGE_SET` | 设置命名范围 | `name`、`baseSheetId?`、`formula?` | +| `NAMED_RANGE_CLEARED` | 清除命名范围 | `name`、`baseSheetId?` | +| `FEATURE_ADDED` / `FEATURE_DELETED` / `FEATURE_UPDATED` | 新增、删除或更新筛选、评论、透视表、保护范围等扩展对象 | `featureType`、`featureId?` | + +扩展对象通常只返回类型、标识和可信 target,不保证包含完整配置。用户询问当前配置时,调用该对象对应的 list/get 命令。 + +`dataValidation` 常用字段: + +| 字段 | 含义 | +|------|------| +| `type` | 验证规则类型 | +| `sourceType` | 下拉来源:`inline` 或 `sourceRange` | +| `options` | 内联下拉选项数组,每项包含 `value` 和可选 `color` | +| `sourceRange` | 已确认的来源工作表与 A1 范围 | +| `sourceRangeStatus` | `RESOLVED`、`UNRESOLVED` 或 `INVALID` | +| `sourceRangeExpression` | 可读的来源表达式 | +| `enableMultiSelect` | 是否允许多选 | +| `criteria` | 非下拉规则的运算符和条件值 | +| `settings` | 是否允许空值、是否显示提示、错误提示等行为设置 | + +只有 `sourceRangeStatus=RESOLVED` 时,才能把 `sourceRange` 当作已确认范围;否则说明来源未解析或无效,并在需要时回读当前规则。 + +### 工作簿设置与其他变更 + +| type | 含义 | 常用 `details` | +|------|------|----------------| +| `WORKBOOK_SETTING_UPDATED` | 更新工作簿设置 | `setting`、`changes` | +| `EXTERNAL_REFERENCES_REPLACED` | 替换外部引用 | `referenceCount` | +| `UNSUPPORTED_CHANGE` | 存在当前无法可靠解释的变更 | `category?` | + +`WORKBOOK_SETTING_UPDATED` 的 `setting` 可表示计算设置或背景设置;只解释 `changes` 中实际返回的字段。遇到 `UNSUPPORTED_CHANGE` 时,只说明存在未能解释的变更,不根据相邻事件猜测其内容。 + +## 常用详情值 + +### 单元格内容 + +`cell` 常见字段: + +- `value`:带类型的值;`kind` 可为 `STRING`、`NUMBER`、`BOOLEAN`、`NULL` 或 `MULTI`,分别读取对应的 `stringValue`、`numberValue`、`booleanValue` 或 `values`。 +- `formula`:本次写入的可读公式。 +- `formulaCleared=true`:本次清除了公式。 +- `cellType`:`general`、`checkbox` 或 `select`;select 可带 `options`。 +- `link`:可用时返回工作簿内范围链接及目标 `sheetId` / `a1Range`。 + +### 清除、null 与字段缺失 + +- 对象整体为 `{ "cleared": true }`:本次清除了该对象。 +- `formulaCleared=true`:本次清除了公式。 +- `styleCleared=true`:本次清除了该相对位置的样式。 +- 对象内某个已知属性为 `null`:本次清除该属性或恢复默认。 +- 字段缺失:本次未提供或不适用;若状态不是 `COMPLETE`,结合 `omissions` 判断缺口。 + +不要把整体清除、单属性清除和字段缺失混为一谈,也不要补造清除前内容。 + +## Agent 解读顺序 + +1. 检查统一输出 `ok=true`、`outcome=success` 和 `data.success=true`。 +2. 直接遍历 `data.changesets`,不要把它当作字符串。 +3. 用 `startRevision` / `endRevision` 准确表述 `(start,end]`。 +4. 先看 `summary.containsStateReset` 和 `summary.containsIncompleteChanges`。 +5. 每个事件先看 `eventType`;`STATE_RESET` 读 `reset`,`EDIT` / `UNDO` 再读 `changes`。 +6. 每条 change 按 `type -> targets -> detailsStatus -> details -> omissions` 解读。 +7. `PARTIAL` 只使用已返回字段并说明缺口;`UNAVAILABLE` 和 `UNSUPPORTED_CHANGE` 不猜。 +8. `isSelfEdit=false` 不用于归因具体用户、系统或自动化。 +9. 用户问“现在是什么”时,按 target 使用对应 read/list/get 命令回读。 +10. 看到可用 revision 不代表已获准回滚;只有用户明确要求并确认覆盖风险后,才按 [sheet-version](./sheet-version.md) 执行 `version revert`。 + +## 最终状态回读 + +changeset 用于解释“某个 revision 提交了什么”,不是“当前最终是什么”。 + +- 值和公式:用 `csv-get`、`range read` 或 `table-get` 回读目标范围。 +- 行列、分组、隐藏和尺寸:用 `sheet info --include ...` 或对应结构读取命令。 +- 数据验证、筛选、评论、透视表等对象:用对应 list/get 命令回读。 +- 粘贴或自动填充:优先回读 `DESTINATION`,不要把 `SOURCE` 当作已修改范围。 +- `STATE_RESET`:重新读取工作簿当前状态。 +- 分段查询时,只要任一段失败或包含不完整变更,就不能声称整个跨度已完整解释。 + +## 常见失败处理 + +- revision 非法或超出范围:先调用 `revision-get`,再修正或分段查询。 +- 区间内容不可用:说明无法获得完整 changeset,不要解释为空 changeset。 +- changeset 过大:缩小 revision 区间;不要假设结果会自动截断。 +- 读取权限不足或无法确认受保护内容的完整读取权限:申请对目标内容的直接访问权限,或请管理员执行;不要把权限错误解释为空数据,也不要拆分范围绕过权限。 +- `containsIncompleteChanges=true`:可以报告已确认部分和明确缺口,不能声称所有改动均已完整解析。 diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md index 20cb2b32..574c0d94 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-style-format.md @@ -99,7 +99,7 @@ Flags: - `--font-line` 为单选:`underline` 置下划线、`line-through` 置删除线、`none` 清除两者 - 至少需传入一个样式参数。单次调用建议:行数 ≤ 1000,单元格总数 ≤ 5000(服务端硬限 30000) - 枚举值按驼峰书写:`autoWrap`、`bold`、`normal`、`center`、`italic` 等 -- 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值),因此**纯样式设置可直接作用于含合并单元格的区域**,无需先取消合并 +- 纯样式设置只修改样式并保留原值,因此可直接作用于含合并单元格的区域,无需先取消合并 ### 批量设置单元格样式(服务端原子事务) ``` @@ -161,7 +161,7 @@ Flags: - 所有项在下发前先做本地校验(每项与 `set-style` 一致:至少一项样式字段 + rows ≤ 1000 + rows×cols ≤ 30000 + 枚举合法),**任一项校验不过即整批不下发** - 批量上限:最多 **100 个区域**,且**所有区域累计不超过 200000 个单元格**(一次请求要把全部单元格矩阵建好,累计量才是真正的峰值约束)。超限同样是下发前本地报错、整批不执行;请拆成多次调用 - `--continue-on-error` 透传给服务端:遇失败继续执行其余项(软批量) -- 底层每项走 `set_cell_range` 的 `cellStyles`/`borderStyles`,因此纯样式批量同样可作用于含合并单元格的区域 +- 批量纯样式设置同样只修改样式并保留原值,可直接作用于含合并单元格的区域 ### 合并单元格 ``` @@ -304,7 +304,7 @@ dws sheet merge-cells --node --sheet-id --range "A1:C3" --m - ★ `range update` / `range set-style` / `range batch-set-style` 单次调用上限(强制):行数 ≤ 1000,单元格总数(行×列)建议≤ 5000(服务端硬限 30000);超限请拆分多次调用。CLI 会在调用前做本地预校验,服务端超 30000 会直接报错 - `range set-style` / `range batch-set-style` 的样式枚举按驼峰书写:`wordWrap` 取 `overflow`/`clip`/`autoWrap`,`fontWeight` 取 `bold`/`normal`,`hAlign` 取 `left`/`center`/`right`/`general`,`vAlign` 取 `top`/`middle`/`bottom`,`fontStyle` 取 `normal`/`italic`;背景色/字体颜色统一使用 `#RRGGBB` 格式 - `--font-line` 取 `none`/`underline`/`line-through`(单选,非驼峰):`underline` 置下划线、`line-through` 置删除线、`none` 同时清除;`--font-family` 传字体族名(如 `Arial`/`微软雅黑`);`--font-style`/`--font-line`/`--font-family` 均为整区共用标量,无 `*-json` 逐格形式 -- `range set-style` / `range batch-set-style` 底层走 `set_cell_range` 的 `cellStyles`(仅设样式、保留原值);纯样式设置可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) +- `range set-style` / `range batch-set-style` 只修改样式并保留原值;可直接作用于**含合并单元格**的区域,无需先取消合并(写值到合并区仍会被拦截) - `range update` 支持通过 `cellStyles` 在写值时附带 per-cell 样式,适合少量单元格写值 + 样式一步到位的场景。批量设置整片区域的统一样式时,仍应使用 `set-style` / `batch-set-style` - `merge-cells` 合并时只保留左上角单元格的值,其他单元格的值会被丢弃 - `merge-cells` 的 `--merge-type` 不传时默认为 `mergeAll`(合并所有单元格) diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md index 3a119745..928e9ad4 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-version.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-version.md @@ -5,10 +5,10 @@ 管理钉钉在线电子表格的历史版本快照。当用户说"保存版本/存个快照/看历史版本/版本列表/回滚到某个版本/恢复到之前的表格"时使用。 - 手动保存当前表格为一个版本快照 → `version save` -- 查看表格的历史版本列表(含版本号、名称、创建人、创建时间) → `version list`(别名 `ls`) -- 把表格回滚到指定历史版本 → `version revert`(危险操作,默认需二次确认) +- 查看表格的历史版本列表 → `version list`(别名 `ls`)。返回不含版本名称;列表项包含 `version`、`type`、`userId`、`createTime`、`updateTime`、`editorList` 等服务端字段 +- 把表格回滚到指定历史版本或已确认的精确 revision → `version revert`(危险操作,默认需二次确认) -三个命令统一用 `--node` 指定表格文档(ID 或 URL)。回滚用的**版本号 `--version` 从 `version list` 的返回中获取**,禁止臆测。 +三个命令统一用 `--node` 指定表格文档(ID 或 URL)。默认从 `version list` 选择稳定的历史版本。用户明确要求恢复到某个精确 revision 时,也可把已从同一工作簿真实查询结果确认的 revision 传给 `--version`,即使它没有出现在版本列表中。禁止猜测版本号或 revision。 ## 命令详细参考 @@ -38,32 +38,47 @@ Flags: --limit int 返回版本数量上限 (可选) --cursor string 分页游标 (可选,游标分页) ``` -返回表格的历史版本列表;回滚前先用它拿到目标 `version`(版本号)。 +返回表格的历史版本列表;顶层包含 `versions`、`nextCursor`、`hasMore`,列表项不含 `name`。回滚前先从列表项拿到真实 `version`(版本号)。 ### 回滚表格到指定版本 ``` Usage: dws sheet version revert [flags] Example: - dws sheet version revert --node SHEET_ID --version 3 --yes + dws sheet version revert --node SHEET_ID --version 3 Flags: --node string 表格文档 ID 或 URL (必填) - --version int 目标版本号 (必填,从 version list 获取) + --version int 目标历史版本或已确认 revision (必填,通常从 version list 获取) ``` -把表格回滚到指定历史版本。**危险操作**:会覆盖当前内容,默认弹出二次确认;AI Agent 或脚本执行时可加全局 `--yes` 跳过确认。 +把表格回滚到指定历史版本或精确 revision。**危险操作**:会覆盖当前内容,默认要求二次确认。本文不提供带 `--yes` 的可复制示例;执行器只有在当前流程中向用户展示完整目标参数和覆盖风险、并获得明确确认后,才可动态追加全局 `--yes`。 + +`version list` 只列选定的保存或回滚点,并不包含每一个 revision。未列入版本列表的 revision 只有在服务端仍可恢复时才能回滚成功;过旧或内容不可用时应直接报告失败,禁止改猜相邻 revision 重试。 + +### 精确 revision 回滚 + +只有用户明确要求恢复到某个 revision 时才走此流程: + +1. 用 `revision-get` 记录回滚前的当前 revision。 +2. 目标 revision 必须来自同一工作簿的真实查询结果,或由用户明确提供;不要根据次数、时间或相邻版本推算。 +3. 向用户展示工作簿、目标 revision 以及“当前内容将被覆盖”的风险,然后停止并等待明确确认;确认前禁止调用回滚工具,也禁止预先添加全局 `--yes`。 +4. 只有用户对当前展示的参数明确确认后,执行器才可对同一条命令动态追加全局 `--yes` 并执行;任一参数发生变化都必须重新确认。 +5. 用 `csv-get`、`range read` 或其他对应读取命令回读所需内容。 +6. 需要审计回滚事件时,用回滚前后的 revision 查询 `changeset-get`,确认出现 `STATE_RESET`,且 `reset.targetRevision` 等于目标 revision。 ## 上下文传递 | 操作 | 从返回中提取 | 用于 | |------|-------------|------| | `version list` | `version`(版本号) | 作为 `version revert --version` 的入参 | +| `revision-get` / `changeset-get` | 已确认属于同一工作簿的 `revision` | 用户明确要求精确恢复时,作为 `version revert --version` 的入参 | | `version save` | 版本快照结果 | 确认已生成快照 | | `version revert` | 回滚结果 | 确认回滚完成,回读表格内容验证 | ## 注意事项 -- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作,默认二次确认;确认无误再执行,脚本/Agent 场景加 `--yes` -- ★ `--version` 必须来自 `version list` 的真实返回,禁止臆测版本号 -- 底层复用 doc 域的历史版本能力(`save_doc_version` / `list_doc_versions` / `revert_doc_version`),`--node` 传在线电子表格文档即可 +- ★ 回滚 `version revert` 会**覆盖当前表格内容**,属危险操作;确认前禁止调用工具。只有用户对当前完整参数明确确认后,执行器才可动态追加全局 `--yes`;任一参数变化都必须重新确认 +- ★ 默认从 `version list` 选择历史版本;只有用户明确要求精确 revision 时,才使用同一工作簿真实查询结果中已确认的 revision +- ★ 未列入版本列表的 revision 不保证仍可恢复;失败时直接说明,不猜测其他 revision,不自动重试 +- ★ `version list` 返回的 `version` 可作为 `changeset-get` 的 start/end 锚点,但相邻历史版本之间可能包含多个 revision。需要查看逐次变更时读 [sheet-revision-changeset](./sheet-revision-changeset.md) - 回滚后应用独立读命令(`csv-get` / `range read`)回读确认,避免"写返回不等于完成" diff --git a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md index 54c4ca48..8299ba06 100644 --- a/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md +++ b/skills/multi/dingtalk-misc/references/sheet/sheet-workbook.md @@ -70,12 +70,12 @@ Flags: --styles string 建表时一并应用的视觉处理 JSON(需与 --values 或 --sheets 搭配) ``` -建表并写入初始数据的多步编排:建文档 → 探活 → 定位默认工作表 → 写数据 → 回读校验 →(可选)应用样式。**只要一个空表格请用 `dws sheet create`**(单次调用);本命令必须给数据。 +本命令会创建表格、写入初始数据并回读校验,还可选择应用样式。**只要一个空表格请用 `dws sheet create`**;本命令必须给数据。 **写入初始数据**(`--values` 与 `--sheets` **二选一,必须给一个**): -- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,内部复用 csv-put 通道,自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码为 CSV 后 2000000 字符。 -- `--sheets`:typed table 数组,一次创建多个带数据的工作表,内部复用 table-put 通道。每项形如 +- `--values`:二维 JSON 数组,裸值写入默认工作表 A1 起。适合单表快速建表,无表头/类型语义,并自动识别数字/布尔。单元格只能是字符串/数字/布尔/null;上限 30000 单元格、编码后 2000000 字符。 +- `--sheets`:typed table 数组,一次创建多个带数据的工作表。每项形如 `{"name":"表名","columns":["列1","列2"],"data":[[...]],"dtypes":{...},"formats":{...},"cellStyles":[...]}`; `name`、`columns` **必填**。第一个条目写入默认工作表(自动重命名为其 `name`,避免残留空表),其余按 `name` 自动新建。 - 字段名为 camelCase,只接受 `name` / `columns` / `data` / `dtypes` / `formats` / `cellStyles` / `startCell` / `mode`(`overwrite`|`append`) / `header` / `allowOverwrite`;**未知键与 snake_case 变体一律拒绝**(服务端会静默丢弃写错的键,`{"datas":[...]}` 会写出一张只有表头的表却报成功) @@ -104,7 +104,7 @@ Flags: **行为要点**: - 所有 JSON 结构、字段类型与枚举都在**创建文档之前**校验(`--sheets` 按 table-put 的输入契约逐字段校验),非法配置直接失败,不会留下白建的空文档 -- 创建后 CLI 会先探活(新建文档服务端仍在初始化,此时写入可能返回成功但不落盘),再写数据 +- 命令会等待新建表格可写后再写入数据,并通过回读确认结果 - 写完会回读**首个预期非空单元格**校验确实落盘(不是死盯 A1:`--sheets` 会按 `startCell` / `header` / `mode` 推算实际写入位置);若未落盘会报错并提示用 `csv-put` / `range update` / `table-put` 补写 - 报错信息里始终带上已创建的 `nodeId`,便于在部分成功时继续操作同一份文档 diff --git a/test/cli/unregistered_modules_command_test.go b/test/cli/unregistered_modules_command_test.go index 1f89476c..89d0f01a 100644 --- a/test/cli/unregistered_modules_command_test.go +++ b/test/cli/unregistered_modules_command_test.go @@ -22,7 +22,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { // All of these commands should be hidden because they are not in // the dynamic server discovery product list. for _, module := range []string{ - "aisearch", "contract", "recruit", + "aisearch", "contract", "chat", "drive", "minutes", "mail", "credit", "credit-risk", "finance", "message", "notify", "doc", } { @@ -30,4 +30,7 @@ func TestRootHelpHidesRecoveredModules(t *testing.T) { t.Fatalf("root help should not show hidden module %q:\n%s", module, got) } } + if !strings.Contains(got, "● recruit ") { + t.Fatalf("root help should show explicitly registered recruit module:\n%s", got) + } } diff --git a/test/fixtures/cli-interface-baseline.txt b/test/fixtures/cli-interface-baseline.txt index db802415..9611e9b7 100644 --- a/test/fixtures/cli-interface-baseline.txt +++ b/test/fixtures/cli-interface-baseline.txt @@ -1,7 +1,7 @@ [root] runnable: true hidden: false - commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki + commands: agoal, aisearch, aitable, api, attendance, audit, auth, cache, calendar, chat, completion, config, contact, dev, devapp, devdoc, ding, doc, doctor, drive, event, help, hrbrain, live, mail, markdown, mcp, minutes, oa, pat, plugin, profile, recovery, recruit, report, schema, sheet, skill, todo, upgrade, version, whiteboard, wiki flags: --client-id:string|required=false|hidden=false|no-opt=""|scope=persistent, --client-secret:string|required=false|hidden=false|no-opt=""|scope=persistent, --debug:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --dry-run:bool|required=false|hidden=false|no-opt="true"|scope=persistent, --fields:string|required=false|hidden=false|no-opt=""|scope=persistent, -f/--format:string|required=false|hidden=false|no-opt=""|scope=persistent, -h/--help:bool|required=false|hidden=false|no-opt="true"|scope=local, --jq:string|required=false|hidden=false|no-opt=""|scope=persistent, --mock:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -o/--output:string|required=false|hidden=true|no-opt=""|scope=persistent, --profile:string|required=false|hidden=false|no-opt=""|scope=persistent, --timeout:int|required=false|hidden=false|no-opt=""|scope=persistent, --token:string|required=false|hidden=true|no-opt=""|scope=persistent, -v/--verbose:bool|required=false|hidden=false|no-opt="true"|scope=persistent, -y/--yes:bool|required=false|hidden=false|no-opt="true"|scope=persistent [agoal] diff --git a/test/scripts/agent_registry_test.go b/test/scripts/agent_registry_test.go new file mode 100644 index 00000000..4b414380 --- /dev/null +++ b/test/scripts/agent_registry_test.go @@ -0,0 +1,279 @@ +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "regexp" + "runtime" + "sort" + "strings" + "testing" +) + +type packagedAgent struct { + universal bool + path string +} + +var upstreamAgentIDs = strings.Fields(` +aider-desk amp antigravity antigravity-cli astrbot autohand-code augment bob +claude-code openclaw cline codearts-agent codebuddy codemaker codestudio codex +command-code continue cortex crush cursor deepagents devin dexto droid eve +firebender forgecode gemini-cli github-copilot goose grok hermes-agent inference-sh +jazz junie iflow-cli kilo kimchi kimi-code-cli kiro-cli kode lingma loaf mcpjam +minimax-code mistral-vibe moxby mux opencode openhands ona pi qoder qoder-cn +qwen-code replit reasonix rovodev roo tabnine-cli terramind tinycloud trae trae-cn +warp windsurf zed zcode zencoder zenflow neovate pochi promptscript adal universal +`) + +func TestAgentRegistriesMatchUpstream76(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + devappPS := readTestFile(t, filepath.Join(root, "scripts", "install-devapp.ps1")) + verifier := readTestFile(t, filepath.Join(root, "scripts", "release", "verify-package-managers.sh")) + + wantIDs := append([]string(nil), upstreamAgentIDs...) + sort.Strings(wantIDs) + registries := map[string]map[string]packagedAgent{ + "install.ps1": parseInstallPowerShellRegistry(t, installPS), + "install-devapp.ps1": parsePipeRegistry(t, between(t, devappPS, "$AgentRegistryRows = @(", ")\n$LegacyAgentCleanupRows")), + "verifier": parsePipeRegistry(t, between(t, verifier, "UPSTREAM_AGENT_REGISTRY='", "'\n\n# DWS-only")), + } + + for name, registry := range registries { + gotIDs := make([]string, 0, len(registry)) + universal := 0 + for id, agent := range registry { + gotIDs = append(gotIDs, id) + if agent.universal { + universal++ + } + } + sort.Strings(gotIDs) + if strings.Join(gotIDs, "\n") != strings.Join(wantIDs, "\n") { + t.Fatalf("%s agent IDs differ from upstream 76\ngot: %v\nwant: %v", name, gotIDs, wantIDs) + } + if universal != 19 || len(registry)-universal != 57 { + t.Fatalf("%s classification = %d universal/%d non-universal, want 19/57", name, universal, len(registry)-universal) + } + } + + canonical := registries["install.ps1"] + for name, got := range registries { + for id, want := range canonical { + if got[id] != want { + t.Errorf("%s[%s] = %+v, want %+v", name, id, got[id], want) + } + } + } + + for id, wantPath := range map[string]string{ + "amp": ".config/agents/skills", + "github-copilot": ".copilot/skills", + "opencode": ".config/opencode/skills", + "windsurf": ".codeium/windsurf/skills", + "cline": ".agents/skills", + "eve": "-", + "promptscript": "-", + } { + if got := canonical[id].path; got != wantPath { + t.Errorf("upstream path for %s = %q, want %q", id, got, wantPath) + } + } +} + +func TestDWSCompatibilityAgentTargets(t *testing.T) { + t.Parallel() + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + legacyBlock := between(t, installPS, "$LegacyAgentCleanupTargets = @(", ")\n\n# Kept as") + if !strings.Contains(legacyBlock, `Id = "dws-qoderwork"; Universal = $false`) { + t.Error("DWS Qoderwork compatibility target must remain non-universal") + } + for _, id := range []string{ + "dws-legacy-github", "dws-legacy-amp", "dws-legacy-cline", "dws-legacy-windsurf", + } { + if !strings.Contains(legacyBlock, `Id = "`+id+`"; Universal = $true`) { + t.Errorf("legacy cleanup registry missing cleanup-only target %s", id) + } + } + for _, id := range upstreamAgentIDs { + if strings.HasPrefix(id, "dws-") { + t.Fatalf("DWS compatibility target %s leaked into upstream registry", id) + } + } +} + +func TestInstallPowerShellUsesAbsoluteJunctionTargetsAndCopyFallback(t *testing.T) { + t.Parallel() + installPS := readTestFile(t, filepath.Join("..", "..", "scripts", "install.ps1")) + for _, want := range []string{ + `$absoluteTarget = [System.IO.Path]::GetFullPath((Join-Path $canonical $name))`, + `New-Item -ItemType Junction -Path (Join-Path $stageRoot $name) -Target $absoluteTarget`, + `Install-MultiToBase -MultiSrc $MultiSrc -BaseDir $baseDir`, + `Install-MonoToBase -SkillSrc $SkillSrc -BaseDir $baseDir`, + } { + if !strings.Contains(installPS, want) { + t.Errorf("install.ps1 missing Windows junction/fallback contract %q", want) + } + } +} + +func TestAgentInstallersCarryUpstreamShallowAndApplicationDetection(t *testing.T) { + t.Parallel() + checks := map[string][]string{ + "build/npm/install.js": {"agentTargetDetected", `case "kimchi"`, `case "tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-skills.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-event.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.sh": {"agent_skill_base_detected", ".config/kimchi/harness/skills", ".tabnine/agent/skills", "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install.ps1": {"Test-AgentSkillBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + "scripts/install-devapp.ps1": {"Test-AgentBaseDetected", `"kimchi"`, `"tabnine-cli"`, "/Applications/ZCode.app", "/Applications/MiniMax Code.app"}, + } + for rel, wants := range checks { + body := readTestFile(t, filepath.Join("..", "..", filepath.FromSlash(rel))) + for _, want := range wants { + if !strings.Contains(body, want) { + t.Errorf("%s missing detection contract %q", rel, want) + } + } + } +} + +func TestInstallPowerShellResolvesCustomAndLegacyAgentHomes(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + + root := filepath.Join("..", "..") + installPS := readTestFile(t, filepath.Join(root, "scripts", "install.ps1")) + cut := strings.LastIndex(installPS, "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + home := t.TempDir() + custom := filepath.Join(t.TempDir(), "custom roots") + xdg := filepath.Join(custom, "xdg") + clawdbot := filepath.Join(home, ".clawdbot") + if err := os.MkdirAll(clawdbot, 0o755); err != nil { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(installPS[:cut], "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +$ids = @("amp", "autohand-code", "claude-code", "codex", "grok", "hermes-agent", "mistral-vibe", "openclaw", "goose", "kimchi", "opencode") +foreach ($id in $ids) { + $agent = $AgentRegistry | Where-Object { $_.Id -eq $id } | Select-Object -First 1 + Write-Output ($id + "|" + (Resolve-AgentSkillBase -Root $env:DWS_TEST_HOME -Agent $agent)) +} +exit 0 +` + harness := filepath.Join(t.TempDir(), "resolve-agent-roots.ps1") + mustWriteFile(t, harness, []byte(prefix), 0o644) + + envRoots := map[string]string{ + "AUTOHAND_HOME": filepath.Join(custom, "autohand"), + "CLAUDE_CONFIG_DIR": filepath.Join(custom, "claude"), + "CODEX_HOME": filepath.Join(custom, "codex"), + "GROK_HOME": filepath.Join(custom, "grok"), + "HERMES_HOME": filepath.Join(custom, "hermes"), + "VIBE_HOME": filepath.Join(custom, "vibe"), + "XDG_CONFIG_HOME": xdg, + } + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harness) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + for key, value := range envRoots { + cmd.Env = append(cmd.Env, key+"="+value) + } + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell root resolver failed: %v\n%s", err, output) + } + want := map[string]string{ + "amp": filepath.Join(xdg, "agents", "skills"), + "autohand-code": filepath.Join(envRoots["AUTOHAND_HOME"], "skills"), + "claude-code": filepath.Join(envRoots["CLAUDE_CONFIG_DIR"], "skills"), + "codex": filepath.Join(envRoots["CODEX_HOME"], "skills"), + "grok": filepath.Join(envRoots["GROK_HOME"], "skills"), + "hermes-agent": filepath.Join(envRoots["HERMES_HOME"], "skills"), + "mistral-vibe": filepath.Join(envRoots["VIBE_HOME"], "skills"), + "openclaw": filepath.Join(clawdbot, "skills"), + "goose": filepath.Join(xdg, "goose", "skills"), + "kimchi": filepath.Join(xdg, "kimchi", "harness", "skills"), + "opencode": filepath.Join(xdg, "opencode", "skills"), + } + got := make(map[string]string) + for _, line := range strings.Split(strings.TrimSpace(string(output)), "\n") { + parts := strings.SplitN(strings.TrimSpace(line), "|", 2) + if len(parts) == 2 { + got[parts[0]] = filepath.Clean(parts[1]) + } + } + for id, path := range want { + if got[id] != filepath.Clean(path) { + t.Errorf("resolved %s = %q, want %q\noutput:\n%s", id, got[id], path, output) + } + } +} + +func readTestFile(t *testing.T, path string) string { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ReadFile(%s): %v", path, err) + } + return string(b) +} + +func between(t *testing.T, text, start, end string) string { + t.Helper() + i := strings.Index(text, start) + if i < 0 { + t.Fatalf("start marker %q missing", start) + } + i += len(start) + j := strings.Index(text[i:], end) + if j < 0 { + t.Fatalf("end marker %q missing", end) + } + return text[i : i+j] +} + +func parseInstallPowerShellRegistry(t *testing.T, text string) map[string]packagedAgent { + t.Helper() + block := between(t, text, "$AgentRegistry = @(", ")\n\n# DWS compatibility targets") + re := regexp.MustCompile(`Id = "([^"]+)"; Universal = \$(true|false); Dir = (?:"([^"]+)"|\$null)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if path == "" { + path = "-" + } + result[match[1]] = packagedAgent{universal: match[2] == "true", path: path} + } + return result +} + +func parsePipeRegistry(t *testing.T, block string) map[string]packagedAgent { + t.Helper() + re := regexp.MustCompile(`([a-z0-9-]+)\|([01])\|([.a-z0-9_\\/-]+)`) + result := make(map[string]packagedAgent) + for _, match := range re.FindAllStringSubmatch(block, -1) { + path := strings.ReplaceAll(match[3], `\`, "/") + if _, exists := result[match[1]]; exists { + t.Fatalf("duplicate agent %q in pipe registry", match[1]) + } + result[match[1]] = packagedAgent{universal: match[2] == "1", path: path} + } + return result +} diff --git a/test/scripts/changelog_pr_gate_test.go b/test/scripts/changelog_pr_gate_test.go index 5d8ef72b..a71385b5 100644 --- a/test/scripts/changelog_pr_gate_test.go +++ b/test/scripts/changelog_pr_gate_test.go @@ -305,6 +305,52 @@ func TestChangelogPRContentOnlyAcceptsReleaseFragmentArchival(t *testing.T) { } } +func TestReleaseFragmentPolicyAcceptsStablePostBetaArchival(t *testing.T) { + repo := newChangelogGateRepo(t) + changelogGateWrite(t, repo.root, ".changes/1236-stable-followup.md", "---\ncategory: Fixed\n---\n\n- Stable follow-up.\n", 0o644) + repo.commit(t, "add post-beta release fragment") + sealBase := strings.TrimSpace(changelogGateGit(t, repo.root, "rev-parse", "HEAD")) + changelogGateWrite(t, repo.root, "CHANGELOG.md", `# Changelog + +## [Unreleased] + +## [1.0.1] - 2026-07-17 + +This release promotes the sealed `+"`v1.0.1-beta.1`"+` contents to stable. + +### Changed + +- Promote the complete beta release to stable. + +### Changes since `+"`v1.0.1-beta.1`"+` + +### Fixed + +- Stable follow-up. + +## [1.0.0] - 2026-07-01 + +### Added + +- Initial release. +`, 0o644) + archiveDir := filepath.Join(repo.root, ".changes", "released", "1.0.1") + if err := os.MkdirAll(archiveDir, 0o755); err != nil { + t.Fatalf("MkdirAll stable archive: %v", err) + } + if err := os.Rename( + filepath.Join(repo.root, ".changes", "1236-stable-followup.md"), + filepath.Join(archiveDir, "1236-stable-followup.md"), + ); err != nil { + t.Fatalf("Rename stable release fragment: %v", err) + } + repo.commit(t, "seal stable release notes") + + if output, err := repo.runFragmentPolicy(t, sealBase, "HEAD"); err != nil { + t.Fatalf("release fragment policy rejected stable seal: %v\noutput:\n%s", err, output) + } +} + func TestReleaseFragmentPolicyRejectsInvalidActiveFragmentAndWrongArchiveVersion(t *testing.T) { t.Run("invalid active fragment", func(t *testing.T) { repo := newChangelogGateRepo(t) diff --git a/test/scripts/install_js_smoke.mjs b/test/scripts/install_js_smoke.mjs index b94bb679..ace89d5c 100644 --- a/test/scripts/install_js_smoke.mjs +++ b/test/scripts/install_js_smoke.mjs @@ -16,23 +16,52 @@ * skills, mono leftover dws/ and stale * dingtalk-* are removed, and the * ~/.dws/skills/{multi,mono} caches fill. - * 2. empty multi/ tree — warns and falls back to mono instead of + * 2. universal topology — canonical store installed, pre-existing private + * copies retired and recoverable under + * ~/.dws/skill-backups//. + * 3. second run — idempotent: an already-correct link is neither + * re-backed-up nor rewritten. + * 4. unretireable universal root — a cleanup-only failure WARNS; canonical + * store, non-universal links and skills-state.json + * are all still written and the install exits 0. + * 5. empty multi/ tree — warns and falls back to mono instead of * crashing postinstall; a previously good multi * cache is NOT wiped. - * 3. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an + * 6. bogus mode — DWS_SKILL_MODE=bogus exits non-zero with an * "invalid DWS_SKILL_MODE" error. - * 4. multi-only zip, mono — mono install is skipped with a warning; the + * 7. multi-only zip, mono — mono install is skipped with a warning; the * staging root is NOT copied into a dws/ dir. - * 5. multi backup failure — preserves mono, writes no multi skill, and + * 8. multi backup failure — preserves mono, writes no multi skill, and * reports postinstall failure. - * 6. mono backup failure — preserves multi, writes no mono skill, and + * 9. mono backup failure — preserves multi, writes no mono skill, and * reports postinstall failure. - * 7. mono switch — migrates only centrally owned multi Skills. - * 8. cache copy failure — preserves the previous complete cache. - * 9. multi publish failure — restores the complete previous Skill set. - * 10. multi backup failure — restores every earlier successful backup. - * 11. mono transaction failure — restores every managed multi Skill after + * 10. mono switch — migrates only centrally owned multi Skills. + * 11. cache copy failure — preserves the previous complete cache. + * 12. no-replace publish — an occupied destination that is not a victim is + * refused, not replaced or linked into. + * 13. simulated win32 — junction type + ABSOLUTE canonical target and + * the rename publish path with its no-replace + * guard, exercised by swapping process.platform + * instead of requiring a Windows host. + * 14. backup retention — ~/.dws/skill-backups keeps the newest 5 stamps. + * 15. unknown preservation — prune never removes non-DWS directories in skill-backups. + * 16. multi publish failure — restores the complete previous Skill set. + * 17. multi backup failure — restores every earlier successful backup. + * 18. mono transaction failure — restores every managed multi Skill after * later backup or mono publish failure. + * 19. copy publish no-replace — mono/multi set publication refuses a + * concurrently occupied destination (atomic + * claim, no check-then-rename window) on + * POSIX and simulated win32. + * 20. unmarked stamps — stamp-shaped directories without the exact + * ownership marker survive pruning and never + * consume a keep slot. + * 24. stamp-root cleanup — a failed marker write removes an empty fresh + * root (rmdir path) but never a non-empty + * foreign root. + * 25. rollback ENOENT gate — a vanished child makes the destination + * unverifiable (rollback refused), never + * "destination already gone". * * Requirements: unix host with tar/zip/unzip on PATH (the same tools * install.js itself shells out to). Skips cleanly on win32. @@ -62,10 +91,36 @@ const repoRoot = path.resolve(path.dirname(url.fileURLToPath(import.meta.url)), const installJsSource = path.join(repoRoot, "build", "npm", "install.js"); const require = createRequire(import.meta.url); const { + UPSTREAM_AGENTS, + resolvedAgentTargets, + agentTargetDetected, + isSkillBackupStamp, + pruneSkillBackups, + backupAndRemoveSkillDir, + movePathRecoverablySync, publishCacheAtomically, + publishCanonicalLinksAtomically, publishManagedMonoSkillSetAtomically, publishManagedMultiSkillSetAtomically, + recordSkillPathPublicationSync, + rollbackPublishedSkillPath, } = require(installJsSource); + +assert.equal(UPSTREAM_AGENTS.length, 76, "the complete upstream Agent registry is pinned"); +assert.equal(new Set(UPSTREAM_AGENTS.map(({ id }) => id)).size, 76, "upstream Agent IDs are unique"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => universal).length, 19, "upstream universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ universal }) => !universal).length, 57, "upstream non-universal classification is pinned"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === null).length, 2, "no-global Agents are retained in the registry"); +assert.equal(UPSTREAM_AGENTS.filter(({ agentDir }) => agentDir === ".agents/skills").length, 6, "canonical-direct Agents need no target"); +assert.equal(resolvedAgentTargets(path.join(os.tmpdir(), "dws-registry-home")).length, 70, "65 upstream roots, qoderwork, and 4 migration roots are deduplicated"); +const detectionHome = fs.mkdtempSync(path.join(os.tmpdir(), "dws-agent-detect-")); +fs.mkdirSync(path.join(detectionHome, ".config", "kimchi"), { recursive: true }); +fs.mkdirSync(path.join(detectionHome, ".tabnine"), { recursive: true }); +const detectionTargets = resolvedAgentTargets(detectionHome); +for (const id of ["kimchi", "tabnine-cli"]) { + assert.equal(agentTargetDetected(detectionTargets.find((target) => target.id === id)), true, `${id} shallow install marker is detected`); +} +fs.rmSync(detectionHome, { recursive: true, force: true }); const assetName = PLATFORM_MAP[`${process.platform}-${process.arch}`]; if (process.platform === "win32" || !assetName) { @@ -90,6 +145,18 @@ function writeFile(filePath, content, mode = 0o644) { fs.writeFileSync(filePath, content, { mode }); } +// Cross-surface backup ownership contract (mirrors internal/upgrade/paths.go, +// scripts/install.ps1, scripts/install-devapp.ps1, and scripts/install.sh): +// every DWS-created stamp root carries .dws-skill-backup whose entire content +// is exactly these bytes. Hardcoded here on purpose so drift from any writer +// fails the smoke test. +const SKILL_BACKUP_MARKER_FILE = ".dws-skill-backup"; +const SKILL_BACKUP_MARKER_BODY = "dws skill backup v1\n"; + +function markBackupStamp(stampRoot) { + writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), SKILL_BACKUP_MARKER_BODY); +} + function writeManagedState(home, names) { const state = { version: "old", @@ -107,6 +174,255 @@ function writeManagedState(home, names) { writeFile(path.join(home, ".dws", "skills-state.json"), `${JSON.stringify(state, null, 2)}\n`); } +function crossDeviceError() { + return Object.assign(new Error("injected cross-device rename"), { code: "EXDEV" }); +} + +// withSimulatedWin32 runs fn with process.platform === "win32" so the +// Windows-only publish branches (junction type, absolute link target, and +// the atomic junction creation at the destination) get real execution +// coverage on a unix host. +// fs.symlinkSync ignores the type argument off Windows, so the same call is +// safe here. Create every temp path BEFORE calling: os.tmpdir() follows the +// simulated platform. +function withSimulatedWin32(fn) { + const original = process.platform; + const set = (value) => + Object.defineProperty(process, "platform", { value, configurable: true, enumerable: true, writable: false }); + set("win32"); + try { + return fn(); + } finally { + set(original); + } +} + +function runCrossDeviceMoveContract() { + const roots = []; + const fixture = (name) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `dws-installjs-exdev-${name}-`)); + roots.push(root); + const src = path.join(root, "external", "skill"); + const dest = path.join(root, "home", ".dws", "skill-backups", "stamp", "skill"); + writeFile(path.join(src, "SKILL.md"), "old skill\n", 0o640); + return { root, src, dest }; + }; + const injectedRename = (source, target) => { + if (path.basename(source) === "skill" && path.basename(target) === "skill") { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }; + try { + { + const { src, dest } = fixture("success"); + fs.symlinkSync("SKILL.md", path.join(src, "skill-link")); + fs.symlinkSync("missing.md", path.join(src, "dangling-link")); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.lstatSync(dest).isDirectory(), true); + assert.equal(fs.statSync(path.join(dest, "SKILL.md")).mode & 0o777, 0o640); + assert.equal(fs.lstatSync(path.join(dest, "skill-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "skill-link")), "SKILL.md"); + assert.equal(fs.lstatSync(path.join(dest, "dangling-link")).isSymbolicLink(), true); + assert.equal(fs.readlinkSync(path.join(dest, "dangling-link")), "missing.md"); + assert.equal(fs.existsSync(src), false, "verified EXDEV move removes source last"); + + const restored = path.join(path.dirname(src), "restored-skill"); + movePathRecoverablySync(dest, restored, { + renameFn(source, target) { + if (source === dest && target === restored) throw crossDeviceError(); + fs.renameSync(source, target); + }, + }); + assert.equal(fs.readFileSync(path.join(restored, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "EXDEV rollback consumes verified backup"); + } + + for (const [name, options] of [ + ["copy", { copyFn() { throw new Error("copy failed"); } }], + ["verify", { verifyFn() { throw new Error("verify failed"); } }], + ]) { + const { root, src, dest } = fixture(name); + assert.throws( + () => movePathRecoverablySync(src, dest, { renameFn: injectedRename, ...options }), + new RegExp(`${name} failed`), + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false); + const backupParent = path.dirname(dest); + if (fs.existsSync(backupParent)) { + assert.equal( + fs.readdirSync(backupParent).some((entry) => entry.startsWith(".skill.cross-device-")), + false, + `${name} failure cleans destination-filesystem staging`, + ); + } + assert.ok(root); + } + + { + const { src, dest } = fixture("remove"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("read-only"); + fs.chmodSync(src, 0o555); + movePathRecoverablySync(src, dest, { renameFn: injectedRename }); + assert.equal(fs.statSync(dest).mode & 0o777, 0o555, "read-only directory mode is restored after publish"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "old skill\n"); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("read-only-remove-failure"); + fs.chmodSync(src, 0o555); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target === src) throw new Error("remove failed"); + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /both copies retained .*remove failed/, + ); + assert.equal(fs.statSync(src).mode & 0o777, 0o555, "failed removal restores source directory mode"); + fs.chmodSync(src, 0o755); + fs.chmodSync(dest, 0o755); + } + + { + const { src, dest } = fixture("permission"); + let copied = false; + const permission = Object.assign(new Error("permission denied"), { code: "EACCES" }); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn() { throw permission; }, + copyFn() { copied = true; }, + }), + /permission denied/, + ); + assert.equal(copied, false, "EACCES must not be mistaken for EXDEV"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const { src, dest } = fixture("dest-verify"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + verifyFn(left, right) { + if (right === dest) throw new Error("dest-verify failed"); + }, + }), + /dest retracted|dest-verify failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy verify failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-chmod"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + chmodFn(target, mode) { + if (target === dest) throw new Error("chmod failed"); + fs.chmodSync(target, mode); + }, + }), + /dest retracted|chmod failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy chmod failure retracts dest"); + } + + { + const { src, dest } = fixture("dest-cleanup"); + assert.throws( + () => movePathRecoverablySync(src, dest, { + renameFn: injectedRename, + removeFn(target) { + if (target !== src && path.basename(target).startsWith(".skill.cross-device-")) { + throw new Error("cleanup failed"); + } + fs.rmSync(target, { recursive: true, force: true }); + }, + }), + /dest retracted|cleanup failed/, + ); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + assert.equal(fs.existsSync(dest), false, "post-occupy staging cleanup failure retracts dest"); + } + + { + const { src, dest } = fixture("same-volume-occupied"); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => movePathRecoverablySync(src, dest), + /already exists/, + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.readFileSync(path.join(src, "SKILL.md"), "utf8"), "old skill\n"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-dangling-collision-")); + roots.push(root); + const home = path.join(root, "home"); + const victim = path.join(home, ".agents", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + const stamp = "20260813-150000"; + const occupied = path.join(home, ".dws", "skill-backups", stamp, ".agents-skills-dingtalk-chat"); + fs.mkdirSync(path.dirname(occupied), { recursive: true }); + fs.symlinkSync("missing-backup", occupied); + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }); + assert.ok(backup.includes(`${stamp}-1`), "dangling backup target selects a numbered sibling"); + assert.equal(fs.readlinkSync(occupied), "missing-backup", "dangling collision is never overwritten"); + } + + { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-link-rollback-")); + roots.push(root); + const home = path.join(root, "home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const victim = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "new\n"); + writeFile(path.join(victim, "SKILL.md"), "old\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [victim], { + renameFn(source, target) { + if (source === victim || (source.includes(`${path.sep}skill-backups${path.sep}`) && target === victim)) { + throw crossDeviceError(); + } + fs.renameSync(source, target); + }, + publishSymlinkFn() { throw new Error("link publish failed"); }, + }), + /link publish failed/, + ); + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old\n"); + } + } finally { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); + } +} + // stagePkg builds a fake npm package whose assets/dws-skills.zip contains // exactly the given zip entries ({ "relative/path": "content" }) plus any // listed empty directories. Returns { tmp, pkg, home }. @@ -133,8 +449,8 @@ function stagePkg(zipEntries, emptyDirs = []) { return { tmp, pkg, home: path.join(tmp, "home") }; } -function runInstall(pkg, home, skillMode) { - const env = { ...process.env, HOME: home }; +function runInstall(pkg, home, skillMode, extraEnv = {}) { + const env = { ...process.env, HOME: home, ...extraEnv }; if (skillMode !== undefined) { env.DWS_SKILL_MODE = skillMode; } else { @@ -193,14 +509,32 @@ scenario("multi install lays out sibling skills and caches", () => { } }); -scenario("Codex uses its canonical root without a generic duplicate", () => { +scenario("pinned universal global topology installs canonical and retires private copies", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", "multi/dws-shared/SKILL.md": "# dws-shared\n", }); try { - writeFile(path.join(home, ".codex", "config.toml"), "model = \"test\"\n"); + // The pinned upstream installer treats global universal Agents as + // canonical-only even when their registry also retains a distinct + // globalSkillsDir for legacy discovery/removal. Exercise every unique + // non-canonical universal root so this distinction cannot drift again. + const retiredUniversalRoots = [ + ".config/agents/skills", + ".gemini/antigravity/skills", + ".gemini/antigravity-cli/skills", + ".codex/skills", + ".cursor/skills", + ".deepagents/agent/skills", + ".firebender/skills", + ".gemini/skills", + ".copilot/skills", + ".config/opencode/skills", + ]; + for (const root of retiredUniversalRoots) { + writeFile(path.join(home, root, "dingtalk-chat", "SKILL.md"), `beta.6 copy in ${root}\n`); + } writeFile( path.join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), "old nested duplicate\n", @@ -209,13 +543,15 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { const res = runInstall(pkg, home, "multi"); assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( - fs.existsSync(path.join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")), - "Codex canonical Skill installed", - ); - assert.ok( - !fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), - "generic flat duplicate not installed", + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "universal canonical Skill installed", ); + for (const root of retiredUniversalRoots) { + assert.ok( + !fs.existsSync(path.join(home, root, "dingtalk-chat")), + `beta.6 universal duplicate migrated from ${root}`, + ); + } assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy nested duplicate retired", @@ -225,7 +561,7 @@ scenario("Codex uses its canonical root without a generic duplicate", () => { } }); -scenario("ZCode uses its canonical root without a generic duplicate", () => { +scenario("ZCode links its Agent root to the canonical store", () => { const { tmp, pkg, home } = stagePkg({ "mono/SKILL.md": "# mono fixture\n", "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", @@ -241,8 +577,10 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); assert.ok( fs.existsSync(path.join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")), - "ZCode canonical Skill installed", + "ZCode Skill resolves through canonical link", ); + assert.ok(fs.lstatSync(path.join(home, ".zcode", "skills", "dingtalk-chat")).isSymbolicLink()); + assert.ok(fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"))); assert.ok( !fs.existsSync(path.join(home, ".agents", "skills", "dws")), "legacy generic duplicate retired", @@ -252,6 +590,172 @@ scenario("ZCode uses its canonical root without a generic duplicate", () => { } }); +scenario("a second install run is idempotent and preserves correct links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + try { + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + + const first = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(first.status, 0, `exit=${first.status}\nstdout=${first.stdout}\nstderr=${first.stderr}`); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + const firstTarget = fs.readlinkSync(linked); + const firstState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + + const second = runInstall(pkg, home, "multi", { XDG_CONFIG_HOME: path.join(home, ".config") }); + assert.equal(second.status, 0, `exit=${second.status}\nstdout=${second.stdout}\nstderr=${second.stderr}`); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "an already-correct link stays a link"); + assert.equal(fs.readlinkSync(linked), firstTarget, "an already-correct link is not rewritten"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + !second.stdout.includes(`已备份并移除 ${linked}`), + "an already-correct link must not be backed up again", + ); + const secondState = JSON.parse(fs.readFileSync(path.join(home, ".dws", "skills-state.json"), "utf8")); + assert.deepEqual(secondState.official_skills, firstState.official_skills, "state is stable across runs"); + assert.deepEqual( + secondState.managed_skills.map((record) => record.digest), + firstState.managed_skills.map((record) => record.digest), + "re-installing the same bundle yields the same provenance digests", + ); + assert.ok( + fs.readdirSync(path.join(home, ".dws", "skill-backups")).length <= 5, + "repeated installs keep the backup root bounded", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("unretireable universal copy warns but still completes the install", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const codexSkills = path.join(home, ".codex", "skills"); + try { + // Non-universal Agent that must still receive its link. + writeFile(path.join(home, ".zcode", "v2", "config.json"), "{}\n"); + // Universal Agent whose obsolete copy cannot be moved out: r-x on the + // parent means rename() fails with EACCES. + writeFile(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + fs.chmodSync(codexSkills, 0o500); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: path.join(home, ".config"), + CODEX_HOME: "", + }); + assert.equal( + res.status, + 0, + `a cleanup-only failure must not fail the install\nexit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`, + ); + assert.match(res.stderr, /旧副本迁移失败/, "the retire failure is reported as a warning"); + assert.ok( + fs.existsSync(path.join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")), + "canonical store is still installed", + ); + const linked = path.join(home, ".zcode", "skills", "dingtalk-chat"); + assert.equal(fs.lstatSync(linked).isSymbolicLink(), true, "non-universal link is still installed"); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + assert.ok( + fs.existsSync(path.join(home, ".dws", "skills-state.json")), + "skills-state.json is written even when a retire failed", + ); + assert.equal( + fs.readFileSync(path.join(codexSkills, "dingtalk-chat", "SKILL.md"), "utf8"), + "old codex copy\n", + "a copy that cannot be backed up is never deleted", + ); + } finally { + try { + fs.chmodSync(codexSkills, 0o700); + } catch {} + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("upstream Agent roots honor XDG and custom homes with relative links", () => { + const { tmp, pkg, home } = stagePkg({ + "mono/SKILL.md": "# mono fixture\n", + "multi/dingtalk-chat/SKILL.md": "# dingtalk-chat\n", + }); + const xdg = path.join(tmp, "xdg config"); + const autohand = path.join(tmp, "autohand home"); + const claude = path.join(tmp, "claude config"); + const codex = path.join(tmp, "codex home"); + const hermes = path.join(tmp, "hermes home"); + try { + writeFile(path.join(xdg, "goose", "config.yaml"), "enabled: true\n"); + writeFile(path.join(xdg, "agents", "detection.marker"), "amp\n"); + writeFile(path.join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), "old amp copy\n"); + writeFile(path.join(autohand, "config.json"), "{}\n"); + writeFile(path.join(claude, "config.json"), "{}\n"); + writeFile(path.join(claude, "skills", "dingtalk-chat", "SKILL.md"), "old claude copy\n"); + writeFile(path.join(codex, "config.toml"), "model=test\n"); + writeFile(path.join(codex, "skills", "dingtalk-chat", "SKILL.md"), "old codex copy\n"); + writeFile(path.join(hermes, "config.json"), "{}\n"); + writeFile(path.join(hermes, "skills", "dingtalk-chat", "SKILL.md"), "old hermes copy\n"); + writeFile(path.join(home, ".qoderwork", "config.json"), "{}\n"); + writeFile(path.join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), "old DWS path\n"); + + const res = runInstall(pkg, home, "multi", { + XDG_CONFIG_HOME: xdg, + AUTOHAND_HOME: autohand, + CLAUDE_CONFIG_DIR: claude, + CODEX_HOME: codex, + HERMES_HOME: hermes, + }); + assert.equal(res.status, 0, `exit=${res.status}\nstdout=${res.stdout}\nstderr=${res.stderr}`); + + for (const linked of [ + path.join(xdg, "goose", "skills", "dingtalk-chat"), + path.join(autohand, "skills", "dingtalk-chat"), + path.join(claude, "skills", "dingtalk-chat"), + path.join(hermes, "skills", "dingtalk-chat"), + path.join(home, ".qoderwork", "skills", "dingtalk-chat"), + ]) { + assert.ok(fs.lstatSync(linked).isSymbolicLink(), `${linked} is linked`); + assert.ok(!path.isAbsolute(fs.readlinkSync(linked)), `${linked} uses a relative link`); + assert.equal(fs.readFileSync(path.join(linked, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + } + const backupText = []; + const backupNames = []; + const collectBackupText = (dir) => { + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + backupNames.push(entry.name); + collectBackupText(full); + } else if (entry.isFile()) backupText.push(fs.readFileSync(full, "utf8")); + } + }; + collectBackupText(path.join(home, ".dws", "skill-backups")); + assert.ok(backupText.includes("old claude copy\n"), "CLAUDE_CONFIG_DIR content is recoverably backed up"); + assert.ok(backupText.includes("old hermes copy\n"), "HERMES_HOME content is recoverably backed up"); + // Retired universal copies are the PR's advertised recovery story: every + // one of them must be readable back out of ~/.dws/skill-backups. + assert.ok(backupText.includes("old amp copy\n"), "retired XDG agents copy is recoverable"); + assert.ok(backupText.includes("old codex copy\n"), "retired CODEX_HOME copy is recoverable"); + assert.ok(backupText.includes("old DWS path\n"), "retired legacy ~/.amp copy is recoverable"); + assert.ok( + backupNames.includes(".amp-skills-dingtalk-chat"), + `backup names record the HOME-relative origin, got ${backupNames.join(",")}`, + ); + for (const retired of [ + path.join(xdg, "agents", "skills", "dingtalk-chat"), + path.join(codex, "skills", "dingtalk-chat"), + path.join(home, ".amp", "skills", "dingtalk-chat"), + ]) { + assert.ok(!fs.existsSync(retired), `legacy/universal duplicate retired: ${retired}`); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("empty multi/ tree falls back to mono and keeps the old multi cache", () => { const { tmp, pkg, home } = stagePkg({ "SKILL.md": "# mono root copy\n", @@ -407,6 +911,514 @@ scenario("cache copy failure preserves the previous complete cache", () => { } }); +scenario("canonical link publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-canonical-race-")); + try { + { + // Real no-replace property: the destination is occupied by data this + // transaction never backed up (it is not a victim), so the production + // publisher must refuse rather than replace or link into it. No stub + // creates or destroys anything here. + const home = path.join(tmp, "no-clobber-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const destination = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + writeFile(path.join(destination, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], []), + /Skill publish destination already exists/, + ); + assert.equal(fs.readFileSync(path.join(destination, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.deepEqual( + fs.readdirSync(destination), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal(fs.lstatSync(destination).isDirectory(), true, "the user's directory is still a directory"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + const home = path.join(tmp, "rollback-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".cursor", "skills"); + const first = path.join(base, "dingtalk-first"); + const second = path.join(base, "dingtalk-second"); + for (const name of ["dingtalk-first", "dingtalk-second"]) { + writeFile(path.join(canonical, name, "SKILL.md"), `new ${name}\n`); + writeFile(path.join(base, name, "SKILL.md"), `old ${name}\n`); + } + let publishCalls = 0; + assert.throws( + () => publishCanonicalLinksAtomically( + home, + canonical, + base, + ["dingtalk-first", "dingtalk-second"], + [first, second], + { + publishSymlinkFn(target, linkPath, type) { + publishCalls += 1; + if (publishCalls === 1) { + fs.symlinkSync(target, linkPath, type); + return; + } + fs.unlinkSync(first); + writeFile(path.join(first, "concurrent-user-data.txt"), "must survive\n"); + throw new Error("injected second canonical publish failure"); + }, + }, + ), + /rollback failed|refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(first, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "unmatched quarantine must be restored onto dest", + ); + assert.ok( + !fs.existsSync(path.join(first, "SKILL.md")), + "restored concurrent dest must not be replaced by the backup", + ); + assert.equal(fs.readFileSync(path.join(second, "SKILL.md"), "utf8"), "old dingtalk-second\n"); + const retained = fs.readdirSync(base) + .filter((name) => name.startsWith(".dingtalk-first.rollback-")) + .map((name) => path.join(base, name, "payload", "concurrent-user-data.txt")) + .filter((candidate) => fs.existsSync(candidate)); + assert.equal(retained.length, 0, "restored concurrent object must not stay hidden in quarantine"); + } + + { + // Direct rollback of a recorded publication after a concurrent writer + // replaced dest: ownership is proven at dest first, so the foreign + // object is never claimed into quarantine. + const dest = path.join(tmp, "direct-rollback", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + fs.rmSync(dest, { recursive: true, force: true }); + writeFile(path.join(dest, "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), + "must survive\n", + "concurrent replacement stays at the original destination", + ); + assert.deepEqual(fs.readdirSync(dest), ["concurrent-user-data.txt"]); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-")), + false, + "unowned dest must not be moved into quarantine", + ); + } + + { + // Dest matched at the pre-check, then the quarantined object no longer + // matches: restore it onto dest with no-replace instead of deleting. + const dest = path.join(tmp, "post-quarantine", "dingtalk-chat"); + writeFile(path.join(dest, "SKILL.md"), "published\n"); + const publication = recordSkillPathPublicationSync(dest); + assert.throws( + () => rollbackPublishedSkillPath(publication, { + quarantineRenameFn(source, target) { + fs.renameSync(source, target); + fs.writeFileSync(path.join(target, "SKILL.md"), "mutated-in-quarantine\n"); + }, + }), + /refusing to delete non-transaction Skill/, + ); + assert.equal( + fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), + "mutated-in-quarantine\n", + "post-quarantine mismatch is restored onto dest", + ); + const parent = path.dirname(dest); + assert.equal( + fs.readdirSync(parent).some((name) => name.startsWith(".dingtalk-chat.rollback-") + && fs.existsSync(path.join(parent, name, "payload", "SKILL.md"))), + false, + "restored dest must not stay hidden in quarantine", + ); + } + + { + // Injected race: a concurrent creator occupies the destination at the + // instant of publication. Link creation must fail atomically with + // EEXIST, leaving the foreign object and its contents completely + // unchanged — nothing is replaced, and nothing is linked into it (the + // old `ln -P` publish treated an occupied directory as a container and + // left its stray link behind because the publication had not yet + // entered the rollback list). + const home = path.join(tmp, "injected-race-home"); + const canonical = path.join(home, ".agents", "skills"); + const base = path.join(home, ".codex", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonical, "dingtalk-chat", "SKILL.md"), "new\n"); + assert.throws( + () => + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(dest), + ["concurrent-user-data.txt"], + "the foreign object is neither replaced nor linked into", + ); + assert.equal(fs.readFileSync(path.join(dest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(fs.lstatSync(dest).isDirectory(), true, "the foreign directory is untouched"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-link-set.tmp-")), + "refused publication cleans its staging root", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("mono and multi set publication never clobbers or deletes concurrent user data", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-copy-race-")); + try { + { + // Real no-replace property for the multi copy publisher: the destination + // is occupied by data this transaction never backed up (it is not a + // victim), so publication must refuse rather than rename over it. The + // claim (mkdir) is the existence check itself — a concurrent creator + // either loses the claim race or is never touched. + const home = path.join(tmp, "multi-home"); + const source = path.join(tmp, "multi-source"); + const base = path.join(home, ".cursor", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + writeFile(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], []), + /Skill publish destination already exists/, + ); + assert.deepEqual( + fs.readdirSync(path.join(base, "dingtalk-second")), + ["concurrent-user-data.txt"], + "refused publication must not leave anything inside the occupied destination", + ); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-second", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Same contract for the mono publisher (canonical publish and the + // copy fallback both flow through it). + const home = path.join(tmp, "mono-home"); + const source = path.join(tmp, "mono-source"); + const base = path.join(home, ".agents", "skills"); + writeFile(path.join(source, "SKILL.md"), "new mono\n"); + writeFile(path.join(base, "dws", "concurrent-user-data.txt"), "must survive\n"); + assert.throws( + () => publishManagedMonoSkillSetAtomically(home, source, base, []), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(path.join(base, "dws")), ["concurrent-user-data.txt"]); + assert.equal(fs.readFileSync(path.join(base, "dws", "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-mono-set.tmp-")), + "refused publication cleans its staging root", + ); + } + + { + // Injected race: a concurrent creator occupies the destination the + // instant before the claim. The old check-then-rename code replaced it; + // the claim must now fail with EEXIST and leave the user object intact. + const home = path.join(tmp, "race-home"); + const source = path.join(tmp, "race-source"); + const base = path.join(home, ".zcode", "skills"); + const secondDest = path.join(base, "dingtalk-second"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(source, "dingtalk-second", "SKILL.md"), "new second\n"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first", "dingtalk-second"], [], { + publishMkdirFn(target) { + if (target === secondDest) { + writeFile(path.join(target, "concurrent-user-data.txt"), "must survive\n"); + } + fs.mkdirSync(target); + }, + }), + /Skill publish destination already exists/, + ); + assert.deepEqual(fs.readdirSync(secondDest), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); + assert.equal(fs.readFileSync(path.join(secondDest, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.ok(!fs.existsSync(path.join(base, "dingtalk-first")), "the earlier publication is rolled back"); + } + + { + // The guarantee holds on the simulated Windows publisher path as well + // (libuv's rename replaces outright there, so the claim matters most). + const home = path.join(tmp, "win32-home"); + const source = path.join(tmp, "win32-source"); + const base = path.join(home, ".qoder", "skills"); + writeFile(path.join(source, "dingtalk-first", "SKILL.md"), "new first\n"); + writeFile(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "must survive\n"); + withSimulatedWin32(() => { + assert.throws( + () => publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-first"], []), + /Skill publish destination already exists/, + ); + }); + assert.deepEqual(fs.readdirSync(path.join(base, "dingtalk-first")), ["concurrent-user-data.txt"]); + assert.equal( + fs.readFileSync(path.join(base, "dingtalk-first", "concurrent-user-data.txt"), "utf8"), + "must survive\n", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("simulated win32 publishes junctions with absolute canonical targets", () => { + // Temp roots are created before entering the simulated platform: os.tmpdir() + // would otherwise resolve through Windows environment variables. + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-win32-link-")); + try { + const home = path.join(tmp, "home"); + const canonical = path.join(home, ".agents", "skills"); + const canonicalSkill = path.join(canonical, "dingtalk-chat"); + const base = path.join(home, ".zcode", "skills"); + const dest = path.join(base, "dingtalk-chat"); + writeFile(path.join(canonicalSkill, "SKILL.md"), "# dingtalk-chat\n"); + writeFile(path.join(dest, "SKILL.md"), "old zcode copy\n"); + + const symlinkCalls = []; + const publishCalls = []; + withSimulatedWin32(() => { + publishCanonicalLinksAtomically(home, canonical, base, ["dingtalk-chat"], [dest], { + symlinkFn(target, linkPath, type) { + symlinkCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + publishSymlinkFn(target, linkPath, type) { + publishCalls.push({ target, linkPath, type }); + fs.symlinkSync(target, linkPath, type); + }, + }); + }); + + assert.equal(symlinkCalls.length, 1, "one staged link per bundle skill"); + assert.equal(symlinkCalls[0].type, "junction", "Windows must stage junctions, not symlinks"); + assert.equal(path.isAbsolute(symlinkCalls[0].target), true, "junctions require an absolute target"); + assert.equal(symlinkCalls[0].target, fs.realpathSync(canonicalSkill), "junction targets the canonical store"); + assert.equal(publishCalls.length, 1, "win32 publishes by creating the junction directly at the destination"); + assert.equal(publishCalls[0].linkPath, dest); + assert.equal(publishCalls[0].type, "junction"); + assert.equal(fs.readlinkSync(dest), fs.realpathSync(canonicalSkill), "published junction kept its absolute target"); + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "# dingtalk-chat\n"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + const stamps = fs.readdirSync(backupRoot); + assert.equal(stamps.length, 1, "the replaced copy produced exactly one backup stamp"); + assert.deepEqual( + fs.readdirSync(path.join(backupRoot, stamps[0])).sort(), + [SKILL_BACKUP_MARKER_FILE, ".zcode-skills-dingtalk-chat"], + "the stamp root carries the ownership marker next to the backup payload", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], SKILL_BACKUP_MARKER_FILE), "utf8"), + SKILL_BACKUP_MARKER_BODY, + "backup creation stamps the exact cross-surface marker bytes", + ); + assert.equal( + fs.readFileSync(path.join(backupRoot, stamps[0], ".zcode-skills-dingtalk-chat", "SKILL.md"), "utf8"), + "old zcode copy\n", + "the replaced copy stays recoverable", + ); + + // FIX 5: an object appearing at the destination at publish time must never + // be replaced — junction creation fails atomically with EEXIST (the old + // win32 rename passed MOVEFILE_REPLACE_EXISTING and replaced it). + const raceBase = path.join(home, ".qoder", "skills"); + const occupied = path.join(raceBase, "dingtalk-chat"); + withSimulatedWin32(() => { + assert.throws( + () => + publishCanonicalLinksAtomically(home, canonical, raceBase, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkPath, type) { + writeFile(path.join(linkPath, "concurrent-user-data.txt"), "must survive\n"); + fs.symlinkSync(target, linkPath, type); + }, + }), + /Skill publish destination already exists/, + ); + }); + assert.deepEqual(fs.readdirSync(occupied), ["concurrent-user-data.txt"], "the concurrent object is neither replaced nor linked into"); + assert.equal(fs.readFileSync(path.join(occupied, "concurrent-user-data.txt"), "utf8"), "must survive\n"); + assert.equal(process.platform !== "win32", true, "the simulated platform is restored"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("skill backups stay bounded to the newest five stamps", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-backup-prune-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + const backup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => "20260102-000000" }); + + const stamps = fs.readdirSync(backupRoot).sort(); + assert.equal(stamps.length, 5, `backup root must stay bounded, got ${stamps.join(",")}`); + assert.ok(stamps.includes("20260102-000000"), "the backup from this run survives pruning"); + assert.ok(!stamps.includes("20260101-000001"), "the oldest stamps are pruned first"); + assert.ok(!stamps.includes("20260101-000003"), "pruning removes exactly the excess"); + assert.ok(stamps.includes("20260101-000004"), "the newest kept stamps are retained"); + assert.equal(path.basename(backup), ".codex-skills-dingtalk-chat"); + assert.equal(fs.readFileSync(path.join(backup, "SKILL.md"), "utf8"), "old codex copy\n"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("prune preserves unknown directories in skill-backups", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unknown-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + for (const stamp of [ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", "20260101-000006", "20260101-000007", + ]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const unknowns = [ + "user-personal-backup", + "20260101-00000", // too few digits in the time part + "20260101-000000-abc", // non-numeric suffix + "notes", + "20260101", // missing time portion + ]; + for (const name of unknowns) { + writeFile(path.join(backupRoot, name, "marker.txt"), `unknown:${name}\n`); + } + + pruneSkillBackups(home); + + const remaining = fs.readdirSync(backupRoot).sort(); + const stamped = remaining.filter((n) => isSkillBackupStamp(n)); + assert.equal(stamped.length, 5, `stamped backups = ${stamped.length}, want 5`); + assert.ok(!stamped.includes("20260101-000001"), "oldest stamps pruned first"); + assert.ok(!stamped.includes("20260101-000002"), "second-oldest pruned"); + assert.ok(stamped.includes("20260101-000005"), "newest kept stamps retained"); + for (const name of unknowns) { + assert.ok(remaining.includes(name), `unknown directory ${name} must survive pruning`); + assert.equal( + fs.readFileSync(path.join(backupRoot, name, "marker.txt"), "utf8"), + `unknown:${name}\n`, + `unknown directory ${name} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("prune preserves unmarked stamp directories without consuming keep slots", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-prune-unmarked-")); + const home = path.join(tmp, "home"); + const backupRoot = path.join(home, ".dws", "skill-backups"); + try { + // Exactly SKILL_BACKUP_KEEP verified stamps: nothing may be pruned, even + // though foreign stamp-shaped siblings would push the naive count past + // the limit. + const marked = ["20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004", "20260101-000005"]; + for (const stamp of marked) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + const foreign = [ + { stamp: "20260102-000001", body: null }, // no marker at all + { stamp: "20260102-000002", body: "dws skill backup v2\n" }, // wrong body + { stamp: "20260101-000000", body: "dws skill backup v1" }, // missing trailing LF + { stamp: "20260101-000099", body: "dws skill backup v1\r\n" }, // CRLF is not the exact contract bytes + ]; + for (const { stamp, body } of foreign) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "user-data.txt"), `foreign:${stamp}\n`); + if (body !== null) writeFile(path.join(stampRoot, SKILL_BACKUP_MARKER_FILE), body); + } + + pruneSkillBackups(home); + + let remaining = fs.readdirSync(backupRoot).sort(); + assert.deepEqual( + remaining, + [...marked, ...foreign.map(({ stamp }) => stamp)].sort(), + "with exactly keep-many verified stamps, nothing is pruned and unmarked stamps never count", + ); + + // Force real pruning: three more verified stamps push marked history to + // eight, so exactly the three oldest verified stamps go while every + // unmarked sibling stays put (still not consuming a keep slot). + for (const stamp of ["20260103-000001", "20260103-000002", "20260103-000003"]) { + const stampRoot = path.join(backupRoot, stamp); + writeFile(path.join(stampRoot, "placeholder", "SKILL.md"), `${stamp}\n`); + markBackupStamp(stampRoot); + } + + pruneSkillBackups(home); + + remaining = fs.readdirSync(backupRoot).sort(); + for (const pruned of marked.slice(0, 3)) { + assert.ok(!remaining.includes(pruned), `oldest verified stamp ${pruned} is pruned`); + } + assert.deepEqual( + remaining, + [marked[3], marked[4], ...foreign.map(({ stamp }) => stamp), "20260103-000001", "20260103-000002", "20260103-000003"].sort(), + "pruning keeps exactly the newest five verified stamps and preserves every unmarked sibling", + ); + for (const { stamp } of foreign) { + assert.equal( + fs.readFileSync(path.join(backupRoot, stamp, "user-data.txt"), "utf8"), + `foreign:${stamp}\n`, + `unmarked stamp ${stamp} contents must be intact`, + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + scenario("multi set publish failure restores the complete previous set", () => { const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-multi-set-")); const home = path.join(tmp, "home"); @@ -431,11 +1443,23 @@ scenario("multi set publish failure restores the complete previous set", () => { [first, second], { renameFn(src, dest) { - if (src.includes(".dws-multi-set.tmp-") && path.basename(src) === "dingtalk-second") { - throw new Error("injected second publish failure"); + if ( + src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (dest === first || dest === second)) + ) { + throw crossDeviceError(); } originalRename(src, dest); }, + // Child files publish through the link primitive now, so the + // injected failure rides the link seam. + publishLinkFn(src, dest) { + if (src.includes(".dws-multi-set.tmp-") && src.includes(`${path.sep}dingtalk-second${path.sep}`)) { + throw new Error("injected second publish failure"); + } + return fs.linkSync(src, dest); + }, }, ), /injected second publish failure/, @@ -516,13 +1540,20 @@ for (const failureKind of ["backup", "publish"]) { } if ( failureKind === "publish" && - src.includes(".dws-mono-set.tmp-") && - path.basename(src) === "dws" + (src === first || + src === second || + (src.includes(`${path.sep}skill-backups${path.sep}`) && (target === first || target === second))) ) { - throw new Error("injected mono publish failure"); + throw crossDeviceError(); } originalRename(src, target); }, + publishLinkFn(src, target) { + if (failureKind === "publish" && src.includes(".dws-mono-set.tmp-")) { + throw new Error("injected mono publish failure"); + } + return fs.linkSync(src, target); + }, }), /injected|failed to back up/, ); @@ -539,6 +1570,216 @@ for (const failureKind of ["backup", "publish"]) { }); } +// A stamp root whose ownership marker write fails must not linger: an empty +// fresh root is removed (rmdir, never a recursive delete), while a +// pre-existing non-empty root holding foreign data always survives. +scenario("a failed marker write never leaves an empty unowned stamp root", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-stamp-cleanup-")); + try { + const home = path.join(tmp, "home"); + const victim = path.join(home, ".codex", "skills", "dingtalk-chat"); + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + + // Fresh root: created by this call, then the marker write is poisoned so + // the backup aborts before any payload moves. + const stamp = "20260801-000000"; + const markerPath = path.join(home, ".dws", "skill-backups", stamp, SKILL_BACKUP_MARKER_FILE); + const originalWriteFileSync = fs.writeFileSync; + fs.writeFileSync = (target, data, options) => { + if (target === markerPath) { + throw Object.assign(new Error("injected marker write failure"), { code: "EACCES" }); + } + return originalWriteFileSync(target, data, options); + }; + try { + assert.throws( + () => backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => stamp }), + /failed to back up Skill directory/, + ); + } finally { + fs.writeFileSync = originalWriteFileSync; + } + assert.equal(fs.readFileSync(path.join(victim, "SKILL.md"), "utf8"), "old codex copy\n", "the source stays in place"); + assert.equal( + fs.existsSync(path.join(home, ".dws", "skill-backups", stamp)), + false, + "an empty unowned stamp root is cleaned up", + ); + + // Foreign non-empty root with the marker path occupied by a directory: + // unusable, so the backup moves to a suffixed root and the foreign root + // is never touched. + const foreignStamp = "20260801-000001"; + const foreignRoot = path.join(home, ".dws", "skill-backups", foreignStamp); + writeFile(path.join(foreignRoot, "user-data.txt"), "foreign\n"); + fs.mkdirSync(path.join(foreignRoot, SKILL_BACKUP_MARKER_FILE)); + const foreignBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => foreignStamp }); + assert.ok(foreignBackup.includes(`${foreignStamp}-1`), `the backup moves to a suffixed root: ${foreignBackup}`); + assert.equal(fs.readFileSync(path.join(foreignRoot, "user-data.txt"), "utf8"), "foreign\n", "a non-empty foreign stamp root survives untouched"); + + // Plain foreign root (ordinary files, no marker): must never be adopted — + // the backup lands in the suffixed root instead, and the foreign root + // never receives our marker. + writeFile(path.join(victim, "SKILL.md"), "old codex copy\n"); + const plainStamp = "20260801-000002"; + const plainRoot = path.join(home, ".dws", "skill-backups", plainStamp); + writeFile(path.join(plainRoot, "user-data.txt"), "foreign\n"); + const plainBackup = backupAndRemoveSkillDir(home, victim, null, { backupStampFn: () => plainStamp }); + assert.ok(plainBackup.includes("20260801-000002-1"), `the backup moves to a suffixed root: ${plainBackup}`); + assert.equal(fs.readFileSync(path.join(plainRoot, "user-data.txt"), "utf8"), "foreign\n", "plain foreign data survives"); + assert.equal(fs.existsSync(path.join(plainRoot, SKILL_BACKUP_MARKER_FILE)), false, "the foreign root never receives our marker"); + assert.equal(fs.readFileSync(path.join(plainBackup, "SKILL.md"), "utf8"), "old codex copy\n", "the payload is intact in the suffixed root"); + assert.equal( + fs.readFileSync(path.join(path.dirname(plainBackup), SKILL_BACKUP_MARKER_FILE), "utf8"), + "dws skill backup v1\n", + "the suffixed root carries the ownership marker", + ); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +// ENOENT during the pre-quarantine identity walk means "already gone" only +// when the destination itself is missing; a concurrently removed CHILD must +// fail the rollback loudly instead of silently skipping it. +scenario("rollback refuses an unverifiable destination whose child vanished", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-rollback-enoent-")); + try { + const dest = path.join(tmp, "skill"); + writeFile(path.join(dest, "SKILL.md"), "payload\n"); + writeFile(path.join(dest, "child.md"), "child\n"); + const publication = recordSkillPathPublicationSync(dest); + + // Remove the child while the pre-quarantine fingerprint walk sits between + // its readdir and the per-child lstat, so the walk itself fails ENOENT. + const firstChild = path.join(dest, "SKILL.md"); + const vanishedChild = path.join(dest, "child.md"); + const originalReadFileSync = fs.readFileSync; + fs.readFileSync = (target, options) => { + if (target === firstChild) { + fs.rmSync(vanishedChild); + } + return originalReadFileSync(target, options); + }; + try { + assert.throws( + () => rollbackPublishedSkillPath(publication), + /refusing to delete unverifiable Skill/, + ); + } finally { + fs.readFileSync = originalReadFileSync; + } + assert.equal(fs.readFileSync(path.join(dest, "SKILL.md"), "utf8"), "payload\n", "the unverifiable destination is preserved"); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +scenario("per-child no-clobber refuses same-name concurrent entries after the claim", () => { + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "dws-installjs-child-race-")); + try { + const buildFixture = (label) => { + const home = path.join(tmp, label); + const source = path.join(tmp, `${label}-src`); + fs.mkdirSync(path.join(source, "dingtalk-chat", "references"), { recursive: true }); + writeFile(path.join(source, "dingtalk-chat", "SKILL.md"), "new chat\n"); + writeFile(path.join(source, "dingtalk-chat", "notes.md"), "notes\n"); + writeFile(path.join(source, "dingtalk-chat", "references", "guide.md"), "guide\n"); + fs.symlinkSync("SKILL.md", path.join(source, "dingtalk-chat", "rel-link")); + return { home, source, base: path.join(home, ".zcode", "skills") }; + }; + + { + // Same-name concurrent FILE: the foreign file lands at + // /SKILL.md between the claim and the per-child link primitive. + const { home, source, base } = buildFixture("file-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "SKILL.md"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishLinkFn(src, target) { + if (target === planted && !fs.existsSync(planted)) { + writeFile(planted, "concurrent-user-edit\n"); + } + return fs.linkSync(src, target); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readFileSync(planted, "utf8"), "concurrent-user-edit\n", "the concurrent file is never replaced"); + assert.deepEqual( + fs.readdirSync(dest).sort(), + ["SKILL.md"], + "no other child may move once the concurrent entry is seen", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent DIRECTORY at /references. + const { home, source, base } = buildFixture("dir-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "references"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishMkdirFn(target) { + if (target === planted && !fs.existsSync(planted)) { + fs.mkdirSync(path.join(planted, "user-data"), { recursive: true }); + writeFile(path.join(planted, "user-data", "keep.txt"), "keep\n"); + } + fs.mkdirSync(target, { mode: 0o700 }); + }, + }), + /Skill move destination already exists/, + ); + assert.equal( + fs.readFileSync(path.join(planted, "user-data", "keep.txt"), "utf8"), + "keep\n", + "the concurrent directory survives byte-identical", + ); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + + { + // Same-name concurrent SYMLINK at /rel-link. + const { home, source, base } = buildFixture("link-race"); + const dest = path.join(base, "dingtalk-chat"); + const planted = path.join(dest, "rel-link"); + assert.throws( + () => + publishManagedMultiSkillSetAtomically(home, source, base, ["dingtalk-chat"], [], { + publishSymlinkFn(target, linkname) { + if (linkname === planted && !fs.existsSync(planted)) { + fs.symlinkSync("foreign-target", planted); + } + return fs.symlinkSync(target, linkname); + }, + }), + /Skill move destination already exists/, + ); + assert.equal(fs.readlinkSync(planted), "foreign-target", "the concurrent link is never replaced"); + assert.ok( + !fs.readdirSync(base).some((name) => name.startsWith(".dws-multi-set.tmp-")), + "failed publish must clean the staging root", + ); + } + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } +}); + +process.stdout.write("• EXDEV copy/verify/remove and transaction rollback contract ... "); +runCrossDeviceMoveContract(); +process.stdout.write("ok\n"); + for (const [name, fn] of scenarios) { process.stdout.write(`• ${name} ... `); fn(); diff --git a/test/scripts/install_script_test.go b/test/scripts/install_script_test.go index d6e299a5..0ff9b5af 100644 --- a/test/scripts/install_script_test.go +++ b/test/scripts/install_script_test.go @@ -4,6 +4,8 @@ import ( "archive/tar" "archive/zip" "compress/gzip" + "crypto/sha256" + "encoding/hex" "os" "os/exec" "path/filepath" @@ -182,13 +184,13 @@ func TestInstallScriptSourceModeInstallsSkillsIntoAgentsDir(t *testing.T) { t.Fatalf("install.sh error = %v\noutput:\n%s", err, string(output)) } - skillPath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws", "SKILL.md") + skillPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws", "SKILL.md") if _, err := os.Stat(skillPath); err != nil { t.Fatalf("Stat(%s) error = %v\noutput:\n%s", skillPath, err, string(output)) } - genericPath := filepath.Join(fixture.fakeHome, ".agents", "skills", "dws") - if _, err := os.Stat(genericPath); !os.IsNotExist(err) { - t.Fatalf("generic Skill root must not duplicate detected Cursor: Stat(%s) = %v\noutput:\n%s", genericPath, err, string(output)) + duplicatePath := filepath.Join(fixture.fakeHome, ".cursor", "skills", "dws") + if _, err := os.Lstat(duplicatePath); !os.IsNotExist(err) { + t.Fatalf("universal Cursor root must not duplicate canonical Skill: Lstat(%s) = %v\noutput:\n%s", duplicatePath, err, string(output)) } } @@ -214,13 +216,1192 @@ func TestInstallPowerShellScriptInstallsToAgentsDir(t *testing.T) { } } +func TestInstallDevappScriptsReportSkillRollbackFailures(t *testing.T) { + t.Parallel() + + checks := []struct { + path string + required string + forbidden string + }{ + {filepath.Join("..", "..", "scripts", "install-devapp.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + {filepath.Join("..", "..", "scripts", "install-devapp.ps1"), "Die \"Skill install failed:", "(skill install skipped:"}, + {filepath.Join("..", "..", "scripts", "install-event.sh"), "Skill rollback failed; backup retained at", "[ -z \"$backup\" ] || mv \"$backup\" \"$dest\" 2>/dev/null || true"}, + } + for _, check := range checks { + data, err := os.ReadFile(check.path) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", check.path, err) + } + text := string(data) + if !strings.Contains(text, check.required) { + t.Errorf("%s missing explicit failure contract %q", check.path, check.required) + } + if strings.Contains(text, check.forbidden) { + t.Errorf("%s still contains silent failure path %q", check.path, check.forbidden) + } + } +} + +func TestInstallScriptsRemoveJunctionStagesLexically(t *testing.T) { + t.Parallel() + + installPs1 := filepath.Join("..", "..", "scripts", "install.ps1") + data, err := os.ReadFile(installPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", installPs1, err) + } + text := string(data) + for _, want := range []string{ + "function Remove-LinkStageRoot", + "Remove-LinkStageRoot -StageRoot $stageRoot", + } { + if !strings.Contains(text, want) { + t.Errorf("install.ps1 missing junction-safe removal contract %q", want) + } + } + // The rollback of published junction paths must go through the lexical + // remover: Windows PowerShell 5.1 follows reparse points during + // Remove-Item -Recurse and could delete the canonical store's contents. + // Assert on the Restore-MultiSkillSet section so identity-anchor + // refactors keep the contract without breaking on variable naming. + begin := strings.Index(text, "function Restore-MultiSkillSet") + if begin < 0 { + t.Fatal("install.ps1 missing Restore-MultiSkillSet") + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + t.Fatal("install.ps1 Restore-MultiSkillSet section not terminated") + } + restore := text[begin : begin+1+end] + if !strings.Contains(restore, "Remove-SkillPathLexically") { + t.Errorf("install.ps1 Restore-MultiSkillSet must remove published paths lexically") + } + if strings.Contains(restore, "Remove-Item") && strings.Contains(restore, "-Recurse") { + t.Errorf("install.ps1 Restore-MultiSkillSet still contains a recursive removal:\n%s", restore) + } + + devappPs1 := filepath.Join("..", "..", "scripts", "install-devapp.ps1") + devappData, err := os.ReadFile(devappPs1) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", devappPs1, err) + } + for _, want := range []string{ + "function Remove-DevLinkStageRoot", + "Remove-DevLinkStageRoot $stageRoot", + } { + if !strings.Contains(string(devappData), want) { + t.Errorf("install-devapp.ps1 missing junction-safe removal contract %q", want) + } + } + + // The backup prune must also be reparse-safe: a stamp tree can hold + // junction/symlink entries at any depth (victims are collected before + // the physical-equality filter), and Windows PowerShell 5.1 follows + // reparse points during Remove-Item -Recurse. + for scriptName, pruneFn := range map[string]string{ + "install.ps1": "Remove-OldSkillBackups", + "install-devapp.ps1": "Remove-OldDevSkillBackups", + } { + scriptData, err := os.ReadFile(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", scriptName, err) + } + body := extractPowerShellFunction(t, string(scriptData), pruneFn) + if strings.Contains(body, "-Recurse") { + t.Errorf("%s %s must delegate to child-first lexical removal, not Remove-Item -Recurse:\n%s", scriptName, pruneFn, body) + } + } +} + +// extractPowerShellFunction returns a `function Name { ... }` body from a +// PowerShell script, terminated by the next top-level function declaration. +func extractPowerShellFunction(t *testing.T, text, name string) string { + t.Helper() + begin := strings.Index(text, "function "+name) + if begin < 0 { + t.Fatalf("function %s not found", name) + } + end := strings.Index(text[begin+1:], "\nfunction ") + if end < 0 { + end = len(text) - begin - 1 + } + return text[begin : begin+1+end] +} + +func TestInstallEventScriptDegradesFailedAgentLoudly(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skipf("unsupported test os %s", runtime.GOOS) + } + if runtime.GOARCH != "amd64" && runtime.GOARCH != "arm64" { + t.Skipf("unsupported test arch %s", runtime.GOARCH) + } + + root := t.TempDir() + fakeHome := filepath.Join(root, "home") + installDir := filepath.Join(root, "bin") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + + assetName := "dws-" + runtime.GOOS + "-" + runtime.GOARCH + ".tar.gz" + if err := os.MkdirAll(releaseDir, 0o755); err != nil { + t.Fatalf("MkdirAll(%s) error = %v", releaseDir, err) + } + writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ + "dws": "fake-event-binary\n", + }) + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "event skill user_im_message_receive_o2o\n", + "multi/dingtalk-shared/SKILL.md": "shared prerequisite\n", + "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", + "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", + }) + writeInstallerFixtureChecksums(t, releaseDir) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + + // A regular file where an agent skill base belongs makes that one agent + // target uninstallable, while later agents (.zcode) stay reachable. + mustWriteFile(t, filepath.Join(fakeHome, ".aider-desk", "skills"), []byte("not a directory\n"), 0o644) + mustWriteFile(t, filepath.Join(fakeHome, ".zcode", "keep.txt"), []byte("zcode home\n"), 0o644) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatalf("Abs(install-event.sh) error = %v", err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+fakeHome, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_INSTALL_DIR="+installDir, + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME="+assetName, + ) + output, err := cmd.CombinedOutput() + if err == nil { + t.Fatalf("install-event.sh should fail loudly when an agent target fails:\n%s", string(output)) + } + got := string(output) + for _, want := range []string{ + "Agent 目标安装失败,已跳过", + "dingtalk-event 分发到 Agent 目录失败", + } { + if !strings.Contains(got, want) { + t.Fatalf("install-event output missing degrade report %q:\n%s", want, got) + } + } + // The failed agent must not abort the loop: later agents still get links. + if _, err := os.Stat(filepath.Join(fakeHome, ".zcode", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("later agent link missing after per-agent degrade: %v\noutput:\n%s", err, got) + } + if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md")); err != nil { + t.Fatalf("canonical Skill missing: %v", err) + } +} + +func TestInstallPowerShellJunctionsResolvePhysicalRoot(t *testing.T) { + // Prefer the advertised Windows PowerShell 5.1 irm|iex surface on Windows; + // pwsh 7 can mask junction removal and resolution differences. + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Probe Test-SamePhysicalSkillRoot with both link flavors: junctions need + // no privilege on Windows but silently no-op on non-Windows pwsh builds; + // symlinks need no privilege on Unix but may fail without Developer Mode + // on Windows. Exit 4 marks "no link flavor testable on this host". + probe := prefix + ` +$canonical = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$other = Join-Path $env:DWS_TEST_HOME '.agents\skills\dingtalk-shared' +New-Item -ItemType Directory -Path $other -Force | Out-Null +$linkParent = Join-Path $env:DWS_TEST_HOME '.zcode\skills' +New-Item -ItemType Directory -Path $linkParent -Force | Out-Null +$asserted = 0 +$junction = Join-Path $linkParent 'dingtalk-chat' +New-Item -ItemType Junction -Path $junction -Target $canonical -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $junction) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $junction -Right $canonical)) { exit 3 } + $junctionChain = Join-Path $linkParent 'dingtalk-chat-chain' + New-Item -ItemType Junction -Path $junctionChain -Target $junction -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $junctionChain -Right $canonical)) { exit 5 } +} +$symlink = Join-Path $linkParent 'dingtalk-shared' +New-Item -ItemType SymbolicLink -Path $symlink -Target $other -ErrorAction SilentlyContinue | Out-Null +if (Test-Path -LiteralPath $symlink) { + $asserted = 1 + if (!(Test-SamePhysicalSkillRoot -Left $symlink -Right $other)) { exit 3 } + $symlinkChain = Join-Path $linkParent 'dingtalk-shared-chain' + New-Item -ItemType SymbolicLink -Path $symlinkChain -Target $symlink -ErrorAction Stop | Out-Null + if (!(Test-SamePhysicalSkillRoot -Left $symlinkChain -Right $other)) { exit 5 } +} +if ($asserted -eq 0) { exit 4 } +if (Test-SamePhysicalSkillRoot -Left $canonical -Right $other) { exit 3 } +if (Test-Path -LiteralPath $junction) { exit 0 } +exit 4 +` + probePath := filepath.Join(t.TempDir(), "junction-probe.ps1") + mustWriteFile(t, probePath, []byte(probe), 0o644) + home := t.TempDir() + probeCmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", probePath) + probeCmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + probeOutput, probeErr := probeCmd.CombinedOutput() + if probeErr != nil { + exitCode := 0 + if exitErr, ok := probeErr.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + if exitCode != 4 { + t.Fatalf("link must resolve to its physical target (lexical Resolve-Path regression):\n%s", string(probeOutput)) + } + return // junctions unavailable on this host (non-Windows pwsh): probed via symlink only + } + + // A rerun over an already-linked agent base must be idempotent: the + // junction is recognized as published and never re-backed-up. + install := prefix + ` +if (!(Install-MultiSkillsToHomes -MultiSrc $env:DWS_TEST_MULTI -Root $env:DWS_TEST_HOME)) { exit 2 } +exit 0 +` + installPath := filepath.Join(t.TempDir(), "junction-idempotent.ps1") + mustWriteFile(t, installPath, []byte(install), 0o644) + multi := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(multi, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".zcode", "v2", "config.json"), []byte("{}\n"), 0o644) + + runInstall := func() string { + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", installPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_MULTI="+multi) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("PowerShell junction idempotency harness failed: %v\n%s", err, string(output)) + } + return string(output) + } + countBackups := func() int { + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + return 0 + } + return len(entries) + } + runInstall() + first := countBackups() + runInstall() + if second := countBackups(); second != first { + t.Fatalf("rerun re-backed-up already-published junctions (backup churn): first=%d second=%d", first, second) + } + linkData, err := os.ReadFile(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")) + if err != nil || string(linkData) != "new chat\n" { + t.Fatalf("linked ZCode Skill content mismatch: %v %q", err, string(linkData)) + } +} + +func TestInstallPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$root = $env:DWS_TEST_HOME +$stage = Join-Path $root 'stage-payload' +$dest = Join-Path $root 'agent\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $stage -Force | Out-Null +New-Item -ItemType Directory -Path $dest -Force | Out-Null +Set-Content -LiteralPath (Join-Path $dest 'user-data.txt') -Value 'keep' +try { + Move-SkillPath -Source $stage -Destination $dest + exit 11 +} catch {} +if (!(Test-Path -LiteralPath $stage -PathType Container)) { exit 12 } +if (Test-Path -LiteralPath (Join-Path $dest 'stage-payload')) { exit 13 } +if (!(Test-Path -LiteralPath (Join-Path $dest 'user-data.txt') -PathType Leaf)) { exit 14 } + +# Simulate a later failure after publication, with another process replacing +# our link before rollback. Identity-aware rollback must retain its directory. +$canonical = Join-Path $root '.agents\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $canonical -Force | Out-Null +$publishedPath = Join-Path $root 'published-link' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +New-Item -ItemType $linkType -Path $publishedPath -Target $canonical -ErrorAction Stop | Out-Null +$record = New-PublishedSkillLinkRecord -Path $publishedPath +Remove-SkillPathLexically -Path $publishedPath +New-Item -ItemType Directory -Path $publishedPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -Value 'keep' +$restored = Restore-MultiSkillSet -Published @($record) -Backups @() +if ($restored) { exit 15 } +if (!(Test-Path -LiteralPath (Join-Path $publishedPath 'concurrent-user-data.txt') -PathType Leaf)) { exit 16 } + +# Copied mono/multi publications use the same identity-protected rollback. +$copySource = Join-Path $root 'copy-source' +$copyPath = Join-Path $root 'published-copy' +New-Item -ItemType Directory -Path $copySource -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copySource 'SKILL.md') -Value 'transaction copy' +Copy-SkillPathLexically -Source $copySource -Destination $copyPath +$copyRecord = New-PublishedSkillCopyRecord -Path $copyPath -Source $copySource +Remove-SkillPathLexically -Path $copyPath +New-Item -ItemType Directory -Path $copyPath -Force | Out-Null +Set-Content -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -Value 'keep' +$copyRestored = Restore-MultiSkillSet -Published @($copyRecord) -Backups @() +if ($copyRestored) { exit 17 } +if (!(Test-Path -LiteralPath (Join-Path $copyPath 'concurrent-copy-data.txt') -PathType Leaf)) { exit 18 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell publication race must retain concurrent directory: %v\n%s", err, string(output)) + } +} + +// The ownership-marker contract (skillBackupMarkerFile / skillBackupMarkerBody, +// declared in skill_backup_prune_test.go) is shared by the Go core +// (internal/upgrade/paths.go), install.sh, both PowerShell installers, and +// build/npm/install.js: every stamp root DWS creates carries the marker, and +// pruning only deletes stamp-named directories whose marker verifies. + +// seedSkillBackupOwnershipFixture fills ~/.dws/skill-backups with five +// verified stamps (marker bytes exactly as the Go core writes them, proving +// cross-surface acceptance) plus three foreign stamp-shaped siblings: two +// without a marker and one with a wrong marker body. +func seedSkillBackupOwnershipFixture(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range []string{ + "20260101-000001", "20260101-000002", "20260101-000003", + "20260101-000004", "20260101-000005", + } { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "placeholder", "SKILL.md"), []byte(stamp+"\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, stamp, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + for _, stamp := range []string{"20260102-000001", "20260102-000002", "20260101-000000"} { + mustWriteFile(t, filepath.Join(backupRoot, stamp, "user-data.txt"), []byte("foreign:"+stamp+"\n"), 0o644) + } + mustWriteFile(t, filepath.Join(backupRoot, "20260101-000000", skillBackupMarkerFile), []byte("dws skill backup v2\n"), 0o644) +} + +// assertSkillBackupOwnershipResult checks the post-prune contract shared by +// both PowerShell installers: the four oldest verified stamps are gone +// (three by the final prune, one by the backup's internal prune), exactly +// five verified stamps survive, every foreign stamp-shaped sibling is intact, +// and the current-run stamp carries the exact marker bytes next to the moved +// payload. +func assertSkillBackupOwnershipResult(t *testing.T, home string) { + t.Helper() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, pruned := range []string{"20260101-000001", "20260101-000002", "20260101-000003", "20260101-000004"} { + if _, err := os.Lstat(filepath.Join(backupRoot, pruned)); !os.IsNotExist(err) { + t.Fatalf("verified stamp %s must be pruned, err = %v", pruned, err) + } + } + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", backupRoot, err) + } + surviving := map[string]bool{} + runStamps := []string{} + for _, entry := range entries { + surviving[entry.Name()] = true + if entry.Name() >= "20260801-000000" { + runStamps = append(runStamps, entry.Name()) + } + } + for _, want := range []string{ + "20260101-000005", + "20260101-000000", "20260102-000001", "20260102-000002", + "20260103-000001", "20260103-000002", "20260103-000003", + } { + if !surviving[want] { + t.Fatalf("stamp %s must survive pruning, surviving = %v", want, surviving) + } + } + if len(runStamps) != 1 || len(entries) != 8 { + t.Fatalf("expected the five newest verified stamps, three foreign stamps, and one run stamp, got %d entries (run stamps %v)", len(entries), runStamps) + } + runRoot := filepath.Join(backupRoot, runStamps[0]) + marker, err := os.ReadFile(filepath.Join(runRoot, skillBackupMarkerFile)) + if err != nil || string(marker) != skillBackupMarkerBody { + t.Fatalf("run stamp marker = %q, %v; want exactly %q", string(marker), err, skillBackupMarkerBody) + } + if backup := findSkillBackupContent(home, "old copy\n"); backup == "" { + t.Fatalf("backed-up Skill content missing from run stamp %s", runRoot) + } + for _, stamp := range []string{"20260101-000000", "20260102-000001", "20260102-000002"} { + data, err := os.ReadFile(filepath.Join(backupRoot, stamp, "user-data.txt")) + if err != nil || string(data) != "foreign:"+stamp+"\n" { + t.Fatalf("foreign stamp %s must be intact, got %q, %v", stamp, string(data), err) + } + } +} + +func TestInstallPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, lookupErr := exec.LookPath(name); lookupErr == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 10 } +} +# A real backup both proves the writer stamps the exact marker bytes and adds +# a current-run root that pruning must keep. +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +if (!(Backup-SkillDir -Dir $victim)) { exit 11 } +if (Test-Path -LiteralPath $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + +func TestInstallDevappPowerShellPruneVerifiesBackupOwnershipMarker(t *testing.T) { + pwsh := "" + if candidate, err := exec.LookPath("pwsh"); err == nil { + pwsh = candidate + } else if runtime.GOOS == "windows" { + if candidate, err := exec.LookPath("powershell"); err == nil { + pwsh = candidate + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +# Five verified stamps plus three foreign stamp-shaped siblings: the foreign +# entries must not count against the keep limit, so nothing is pruned here. +Remove-OldDevSkillBackups +foreach ($stamp in @('20260101-000001','20260101-000002','20260101-000003','20260101-000004','20260101-000005','20260101-000000','20260102-000001','20260102-000002')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 10 } +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), "old copy` + "`n" + `") +Backup-DevSkill $victim +if (Test-PathLexically $victim) { exit 12 } +foreach ($stamp in @('20260103-000001','20260103-000002','20260103-000003')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'placeholder') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + seedSkillBackupOwnershipFixture(t, home) + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-marker.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp backup marker harness failed: %v\n%s", err, string(output)) + } + assertSkillBackupOwnershipResult(t, home) +} + +// lookPowerShellForScripts prefers Windows PowerShell 5.1 (the advertised +// irm|iex surface) on Windows and falls back to pwsh everywhere. +func lookPowerShellForScripts(t *testing.T) string { + t.Helper() + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + return candidate + } + } + t.Skip("PowerShell is not available") + return "" +} + +// TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets pins +// the Blocker 2 fix: pruning a stamp root that contains a junction/symlink +// child must delete the link itself while the link target's contents survive. +// Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse, +// so the prune removes children lexically, recursing real directories only. +func TestInstallPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + // Junctions need no privilege on Windows but silently no-op on non-Windows + // pwsh builds; symlinks need no privilege on Unix. Exit 4 marks "no link + // flavor testable on this host". + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +# The oldest of six verified stamps is prunable and holds a link child. +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-SkillBackupMarker -Root $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-Path -LiteralPath $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-SkillBackupMarker -Root $stampRoot +} +Remove-OldSkillBackups +if (Test-Path -LiteralPath $oldest) { exit 5 } +if (!(Test-Path -LiteralPath (Join-Path $precious 'target-data.txt') -PathType Leaf)) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-Path -LiteralPath (Join-Path $backupRoot $stamp) -PathType Container)) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + surviving = append(surviving, entry.Name()) + } + want := []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} + if len(surviving) != len(want) { + t.Fatalf("after pruning the link-bearing stamp, surviving stamps = %v, want %v", surviving, want) + } + for _, stamp := range want { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets +// is the install-devapp.ps1 twin of the link-aware prune contract above. +func TestInstallDevappPowerShellPruneRemovesLinkChildrenWithoutFollowingTargets(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +$linkType = if ([System.Environment]::OSVersion.Platform -eq [System.PlatformID]::Win32NT) { 'Junction' } else { 'SymbolicLink' } +$precious = Join-Path $env:DWS_TEST_HOME 'precious-store' +New-Item -ItemType Directory -Path $precious -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $precious 'target-data.txt'), 'must survive') +$oldest = Join-Path $backupRoot '20200101-000001' +New-Item -ItemType Directory -Path (Join-Path $oldest 'payload') -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $oldest 'payload\SKILL.md'), 'old') +Write-DevSkillBackupMarker $oldest +$link = Join-Path $oldest 'linked-skill' +New-Item -ItemType $linkType -Path $link -Target $precious -ErrorAction SilentlyContinue | Out-Null +if (!(Test-PathLexically $link)) { exit 4 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + $stampRoot = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path (Join-Path $stampRoot 'payload') -Force | Out-Null + Write-DevSkillBackupMarker $stampRoot +} +Remove-OldDevSkillBackups +if (Test-PathLexically $oldest) { exit 5 } +if (!(Test-PathLexically (Join-Path $precious 'target-data.txt'))) { exit 6 } +foreach ($stamp in @('20200101-000002','20200101-000003','20200101-000004','20200101-000005','20200101-000006')) { + if (!(Test-PathLexically (Join-Path $backupRoot $stamp))) { exit 7 } +} +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "devapp-skill-backup-link-prune.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + output, err := cmd.CombinedOutput() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 4 { + t.Skipf("no junction/symlink flavor can be created on this host:\n%s", string(output)) + } + t.Fatalf("PowerShell devapp link-aware prune harness failed: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(home, "precious-store", "target-data.txt")); err != nil || string(got) != "must survive" { + t.Fatalf("link target content = %q, %v; want it to survive pruning", got, err) + } + for _, stamp := range []string{"20200101-000002", "20200101-000003", "20200101-000004", "20200101-000005", "20200101-000006"} { + if _, err := os.Stat(filepath.Join(home, ".dws", "skill-backups", stamp)); err != nil { + t.Fatalf("stamp %s must survive pruning: %v", stamp, err) + } + } +} + +// TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot pins the +// collision-loop fix: a stamp root that exists without the verified +// ownership marker is foreign data, so Backup-SkillDir bumps to a -N root +// instead of writing its marker (and later prune eligibility) into it. +// Foreign roots are seeded for the current second and the next nine so the +// stamp Backup-SkillDir picks is always a pre-existing unverified root. +func TestInstallPowerShellBackupNeverClaimsUnverifiedStampRoot(t *testing.T) { + pwsh := lookPowerShellForScripts(t) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$backupRoot = Join-Path $env:DWS_TEST_HOME '.dws\skill-backups' +New-Item -ItemType Directory -Path $backupRoot -Force | Out-Null +$now = [DateTime]::UtcNow +$seeded = @() +for ($i = 0; $i -lt 10; $i++) { + $stamp = $now.AddSeconds($i).ToString('yyyyMMdd-HHmmss') + $seeded += $stamp + $foreign = Join-Path $backupRoot $stamp + New-Item -ItemType Directory -Path $foreign -Force | Out-Null + [System.IO.File]::WriteAllText((Join-Path $foreign 'user-data.txt'), 'foreign') +} +$victim = Join-Path $env:DWS_TEST_HOME '.zcode\skills\dingtalk-chat' +New-Item -ItemType Directory -Path $victim -Force | Out-Null +[System.IO.File]::WriteAllText((Join-Path $victim 'SKILL.md'), 'old copy') +$backupPath = '' +if (!(Backup-SkillDir -Dir $victim -BackupPath ([ref]$backupPath))) { exit 2 } +if (!$backupPath) { exit 3 } +foreach ($stamp in $seeded) { + $foreign = Join-Path $backupRoot $stamp + if (Test-Path -LiteralPath (Join-Path $foreign '.dws-skill-backup')) { exit 4 } + $children = @(Get-ChildItem -LiteralPath $foreign -Force | ForEach-Object { $_.Name }) + if ($children.Count -ne 1 -or $children[0] -ne 'user-data.txt') { exit 5 } +} +# Every plain stamp root was foreign, so the payload must sit in a -N root. +if ($backupPath -notmatch '-[0-9]+[\\/]\.zcode-skills-dingtalk-chat$') { exit 6 } +if (!(Test-Path -LiteralPath (Join-Path $backupPath 'SKILL.md') -PathType Leaf)) { exit 7 } +exit 0 +` + home := filepath.Join(t.TempDir(), "home") + harnessPath := filepath.Join(t.TempDir(), "skill-backup-collision.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell backup collision harness failed: %v\n%s", err, string(output)) + } + // The foreign same-second roots stay untouched: no marker, no payload. + entries, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups")) + if err != nil { + t.Fatal(err) + } + foreignIntact, bumped := 0, "" + for _, entry := range entries { + children, err := os.ReadDir(filepath.Join(home, ".dws", "skill-backups", entry.Name())) + if err != nil { + t.Fatal(err) + } + hasPayload, hasMarker := false, false + for _, child := range children { + if child.Name() == ".dws-skill-backup" { + hasMarker = true + } + if child.Name() == ".zcode-skills-dingtalk-chat" { + hasPayload = true + } + } + if hasPayload { + bumped = entry.Name() + if !hasMarker { + t.Fatalf("backup stamp root %s must carry the ownership marker", bumped) + } + continue + } + if hasMarker { + t.Fatalf("foreign stamp root %s must never be marked DWS-owned", entry.Name()) + } + if len(children) != 1 || children[0].Name() != "user-data.txt" { + t.Fatalf("foreign stamp root %s must stay untouched, children = %v", entry.Name(), children) + } + foreignIntact++ + } + if foreignIntact != 10 || bumped == "" || !strings.Contains(bumped, "-") { + t.Fatalf("want 10 untouched foreign roots plus one -N backup root, got %d intact and backup root %q", foreignIntact, bumped) + } +} + +// TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication pins the +// path-blind retract fix: after a cross-device publish, a post-occupy +// verification failure must retract the destination only through +// Remove-VerifiedDevSkillPublication, so a concurrently replaced destination +// is restored instead of deleted. +func TestInstallDevappPowerShellCrossDeviceRetractVerifiesPublication(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + if err := os.MkdirAll(home, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +# The first move throws the cross-device error, so the recoverable path +# stages and publishes a copy; right after that publish another process +# replaces the destination, and post-occupy verification fails. +$script:ReplacedPublished = $false +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop + if (!$script:ReplacedPublished -and $Destination -eq $env:DWS_TEST_DESTINATION) { + $script:ReplacedPublished = $true + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Destination (Join-Path $env:DWS_TEST_HOME 'replaced-published') -ErrorAction Stop + New-Item -ItemType Directory -Path $Destination -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $Destination 'concurrent-user-data.txt') -Value 'keep' + } +} +try { + Move-DevSkillPathRecoverably $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_.Exception.Message -notmatch 'state uncertain') { Write-Error $_; exit 3 } +} +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt'))) { exit 4 } +if (!(Test-PathLexically (Join-Path $env:DWS_TEST_SOURCE 'SKILL.md'))) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-retract-verify-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp verified retract must retain the concurrent replacement: %v\n%s", err, string(output)) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent replacement data = %q, %v; want it retained", got, err) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "new dev skill\n" { + t.Fatalf("source must be retained after the failed cross-device move, got %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(home, "replaced-published")); err != nil { + t.Fatalf("the replaced original copy must stay inspectable, not be deleted: %v", err) + } + for _, pattern := range []string{".dws-dev-rollback-*", ".dingtalk-misc.cross-device-*"} { + if matches, err := filepath.Glob(filepath.Join(root, "external", pattern)); err != nil || len(matches) != 0 { + t.Fatalf("devapp rollback leftovers %s = %v, %v", pattern, matches, err) + } + } +} + +func TestInstallScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.sh main section not found") + } + harness := string(data[:cut]) + ` +ROOT="$DWS_TEST_HOME/root" +BASE="$ROOT/agent/skills" +CANONICAL="$ROOT/.agents/skills" +BUNDLE="$DWS_TEST_HOME/bundle" +mkdir -p "$CANONICAL/dingtalk-chat" "$BASE" "$BUNDLE/dingtalk-chat" || exit 9 +printf 'canonical\n' > "$CANONICAL/dingtalk-chat/SKILL.md" || exit 9 +printf 'bundled\n' > "$BUNDLE/dingtalk-chat/SKILL.md" || exit 9 + +race_case="$1" +dest="$BASE/dingtalk-chat" + +# Inject the concurrent writer at the exact instant before publication: the +# publish loop reads every staged link first, so the override occupies the +# destination right before the real creation attempt. The override runs in a +# command-substitution subshell, so only its on-disk effects are asserted. +readlink() { + case "$1" in + *.dws-link-set.*dingtalk-chat) + case "$race_case" in + file) printf 'must survive\n' > "$dest" ;; + dir) + mkdir -p "$dest" || exit 9 + printf 'must survive\n' > "$dest/concurrent-user-data.txt" || exit 9 + ;; + esac + ;; + esac + command readlink "$1" +} + +if link_canonical_skills_to_base "$ROOT" "$BASE" multi "$BUNDLE"; then + printf 'UNEXPECTED-SUCCESS\n' + exit 1 +fi +case "$race_case" in + file) + [ -f "$dest" ] || { printf 'MISSING-FILE\n'; exit 3; } + [ "$(cat "$dest")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; } + ;; + dir) + [ -d "$dest" ] || { printf 'MISSING-DIR\n'; exit 3; } + [ "$(cat "$dest/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; } + if [ -n "$(ls -A "$dest" | grep -v '^concurrent-user-data\.txt$')" ]; then + printf 'NESTED-LEFTOVER\n' + exit 5 + fi + ;; +esac +case "$(ls -A "$BASE")" in + *".dws-link-set."*) printf 'STAGE-LEAK\n'; exit 6 ;; +esac +exit 0 +` + for _, raceCase := range []string{"file", "dir"} { + harnessPath := filepath.Join(t.TempDir(), "link-publication-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath, raceCase) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir()) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("install.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output)) + } + } +} + +func TestInstallEventScriptLinkPublicationRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-event.sh main call not found") + } + harness := string(data[:cut]) + ` +ROOT="$DWS_TEST_HOME" +CANONICAL="$ROOT/.agents/skills/dingtalk-chat" +SRC="$ROOT/src/dingtalk-chat" +DEST="$ROOT/agent/skills/dingtalk-chat" +mkdir -p "$CANONICAL" "$SRC" || exit 9 +printf 'canonical\n' > "$CANONICAL/SKILL.md" || exit 9 +printf 'src\n' > "$SRC/SKILL.md" || exit 9 + +# Inject the concurrent writer at the exact instant before publication: +# backup_skill_dir is the last call before the link is created at DEST. The +# override runs in a command-substitution subshell, so only its on-disk +# effects are asserted. +backup_skill_dir() { + case "$RACE_CASE" in + file) printf 'must survive\n' > "$DEST" || exit 9 ;; + dir) + mkdir -p "$DEST" || exit 9 + printf 'must survive\n' > "$DEST/concurrent-user-data.txt" || exit 9 + ;; + esac + printf '\n' +} + +if link_or_copy_skill "$CANONICAL" "$SRC" "$DEST"; then + printf 'UNEXPECTED-SUCCESS\n' + exit 1 +fi +case "$RACE_CASE" in + file) + [ -f "$DEST" ] || { printf 'MISSING-FILE\n'; exit 3; } + [ "$(cat "$DEST")" = "must survive" ] || { printf 'FILE-CONTENT\n'; exit 4; } + ;; + dir) + [ -d "$DEST" ] || { printf 'MISSING-DIR\n'; exit 3; } + [ "$(cat "$DEST/concurrent-user-data.txt")" = "must survive" ] || { printf 'DIR-CONTENT\n'; exit 4; } + if [ -n "$(ls -A "$DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then + printf 'NESTED-LEFTOVER\n' + exit 5 + fi + ;; +esac +exit 0 +` + for _, raceCase := range []string{"file", "dir"} { + harnessPath := filepath.Join(t.TempDir(), "event-link-publication-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+t.TempDir(), "EVENT_VERSION=1.0.0-test", "RACE_CASE="+raceCase) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("install-event.sh link publication race (%s) must preserve the concurrent object: %v\n%s", raceCase, err, string(output)) + } + } +} + +func TestInstallerStandaloneCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + if [ "$1" = "$DWS_TEST_DEST" ]; then + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" +} +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +[ -f "$DWS_TEST_DEST/concurrent-user-data.txt" ] || exit 3 +[ "$(cat "$DWS_TEST_DEST/concurrent-user-data.txt")" = "must-survive" ] || exit 4 +if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^concurrent-user-data\.txt$')" ]; then + exit 5 +fi +[ -n "$(find "$HOME/.dws/skill-backups" -name SKILL.md -print 2>/dev/null)" ] || exit 6 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy publication race must preserve the concurrent object: %v\n%s", scriptName, err, output) + } + got, err := os.ReadFile(filepath.Join(dest, "concurrent-user-data.txt")) + if err != nil || strings.TrimSpace(string(got)) != "must-survive" { + t.Fatalf("%s concurrent dest = %q, %v", scriptName, got, err) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish", scriptName) + } + }) + } +} + func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { t.Parallel() for _, rel := range []string{ filepath.Join("..", "..", "scripts", "install.sh"), filepath.Join("..", "..", "scripts", "install-event.sh"), + filepath.Join("..", "..", "scripts", "install-devapp.sh"), filepath.Join("..", "..", "scripts", "install.ps1"), + filepath.Join("..", "..", "scripts", "install-devapp.ps1"), filepath.Join("..", "..", "scripts", "install-skills.sh"), } { scriptPath, err := filepath.Abs(rel) @@ -237,12 +1418,126 @@ func TestInstallScriptsUseGitHubReleaseSkillsAsset(t *testing.T) { if !strings.Contains(text, "releases/download") || !strings.Contains(text, "dws-skills.zip") { t.Fatalf("%s should download dws-skills.zip from GitHub Releases", scriptPath) } + if !strings.Contains(text, "checksums.txt") || !strings.Contains(strings.ToLower(text), "sha256") { + t.Fatalf("%s should verify dws-skills.zip against release checksums", scriptPath) + } if strings.Contains(text, "archive/refs/heads/main.tar.gz") || strings.Contains(text, "archive/refs/tags/") { t.Fatalf("%s should not download skills from repository archive refs", scriptPath) } } } +func TestInstallerStandaloneCopyChildRacePreservesConcurrentObjects(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + // sameName: a concurrently created same-name empty child directory must + // refuse the publish (a POSIX mv would have replaced it wholesale). + // foreignName: a different-named entry landing inside the claimed dest + // mid-publish aborts the transaction with the destination retained. + for _, scriptName := range []string{"install-event.sh", "install-devapp.sh"} { + for _, raceCase := range []string{"same-name", "foreign-name"} { + scriptName, raceCase := scriptName, raceCase + t.Run(scriptName+"/"+raceCase, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + src := filepath.Join(t.TempDir(), "src-skill") + dest := filepath.Join(home, "agent", "skills", "dingtalk-misc") + mustWriteFile(t, filepath.Join(src, "SKILL.md"), []byte("new skill\n"), 0o644) + if err := os.MkdirAll(filepath.Join(src, "aaa-subdir"), 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(src, "aaa-subdir", "inner.txt"), []byte("inner\n"), 0o644) + mustWriteFile(t, filepath.Join(dest, "SKILL.md"), []byte("old skill\n"), 0o644) + + harness := `. "$DWS_TEST_LIBRARY" +case "$RACE_CASE" in + same-name) + mkdir() { + if [ "$1" = "$DWS_TEST_DEST/aaa-subdir" ]; then + # The concurrent writer takes the child path as an empty directory + # between the top-level claim and the child publish. POSIX mv would + # replace it; the no-clobber child primitive must refuse instead. + command mkdir -p "$1" + printf '%s\n' must-survive > "$1/concurrent-user-data.txt" + return 1 + fi + command mkdir "$@" + } + ;; + foreign-name) + ln() { + if [ "$2" = "$DWS_TEST_DEST/SKILL.md" ]; then + command ln "$@" + # A different-named foreign entry lands inside the claim while the + # remaining staged children are still publishing. + printf '%s\n' must-survive > "$DWS_TEST_DEST/zzz-foreign" + return 0 + fi + command ln "$@" + } + ;; +esac +if copy_tree "$DWS_TEST_SRC" "$DWS_TEST_DEST"; then + exit 2 +fi +case "$RACE_CASE" in + same-name) + [ -f "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/aaa-subdir/concurrent-user-data.txt")" = "must-survive" ] || exit 4 + [ ! -e "$DWS_TEST_DEST/aaa-subdir/inner.txt" ] || exit 5 + [ ! -e "$DWS_TEST_DEST/SKILL.md" ] || exit 6 + ;; + foreign-name) + [ -f "$DWS_TEST_DEST/zzz-foreign" ] || exit 3 + [ "$(cat "$DWS_TEST_DEST/zzz-foreign")" = "must-survive" ] || exit 4 + # The transaction's own children are retracted; only the foreign entry + # remains in the retained destination. + if [ -n "$(ls -A "$DWS_TEST_DEST" | grep -v '^zzz-foreign$')" ]; then + ls -A "$DWS_TEST_DEST" >&2 + exit 5 + fi + ;; +esac +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "copy-child-race.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", harnessPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SRC="+src, + "DWS_TEST_DEST="+dest, + "RACE_CASE="+raceCase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s copy child race (%s) must preserve the concurrent object: %v\n%s", scriptName, raceCase, err, string(output)) + } + if backup := findSkillBackupContent(home, "old skill\n"); backup == "" { + t.Fatalf("%s original backup must be retained after refused publish (%s)", scriptName, raceCase) + } + }) + } + } +} + func TestInstallEventScriptStaticExpectations(t *testing.T) { t.Parallel() @@ -319,6 +1614,7 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", "SKILL.md": "legacy mono root\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, dir := range []string{ @@ -381,17 +1677,23 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { } expectedSkills := map[string]string{ - ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".config/opencode/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", - ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".codex/skills/dws/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", - ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", - ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", - ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".agents/skills/dingtalk-shared/SKILL.md": "shared prerequisite", + ".agents/skills/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".agents/skills/dws/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-event/SKILL.md": "user_im_message_receive_o2o", + ".dws/skills/multi/dingtalk-shared/SKILL.md": "shared prerequisite", + ".dws/skills/multi/dingtalk-misc/SKILL.md": "clean misc oa routing", + ".dws/skills/mono/SKILL.md": "user_im_message_receive_o2o", + } + for _, duplicate := range []string{ + filepath.Join(fakeHome, ".codex", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".config", "opencode", "skills", "dingtalk-event"), + filepath.Join(fakeHome, ".codex", "skills", "dws"), + } { + if _, err := os.Lstat(duplicate); !os.IsNotExist(err) { + t.Fatalf("universal Agent duplicate remains at %s: %v", duplicate, err) + } } for rel, marker := range expectedSkills { p := filepath.Join(fakeHome, filepath.FromSlash(rel)) @@ -410,6 +1712,15 @@ func TestInstallEventScriptInstallsBinaryAndEventSkills(t *testing.T) { if err != nil || string(sibling) != "keep sibling\n" { t.Fatalf("unrelated sibling changed: data=%q err=%v", sibling, err) } + // The replaced standalone Event skill must sit in a marker-stamped backup + // root: that marker is what lets a later prune count and recycle it. The + // event installer archives under the flattened source path, so locate the + // backup by content and the stamp root two levels above it. + if got := findSkillBackupContent(fakeHome, "old standalone event\n"); got == "" { + t.Fatalf("replaced standalone Event skill should be backed up under .dws/skill-backups") + } else { + assertSkillBackupMarker(t, filepath.Dir(filepath.Dir(got))) + } if _, err := os.Stat(filepath.Join(fakeHome, ".agents", "skills", "dingtalk-dev")); !os.IsNotExist(err) { t.Fatalf("dingtalk-dev should not be installed by install-event.sh, stat err=%v", err) } @@ -433,6 +1744,7 @@ func TestInstallEventScriptSkillsOnlySkipsBinary(t *testing.T) { "multi/dingtalk-misc/SKILL.md": "clean misc oa routing\n", "mono/SKILL.md": "mono skill user_im_message_receive_o2o\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) @@ -478,6 +1790,7 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { "multi/dingtalk-shared/SKILL.md": "new shared\n", "mono/SKILL.md": "new mono\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) existing := filepath.Join(fakeHome, ".agents", "skills", "dingtalk-event", "SKILL.md") @@ -512,6 +1825,46 @@ func TestInstallEventScriptPreflightFailureDoesNotChangeSkills(t *testing.T) { } } +func TestInstallEventChecksumMismatchPreservesExistingSkills(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell installer test is for unix-like hosts") + } + root := t.TempDir() + home := filepath.Join(root, "home") + releaseDir := filepath.Join(root, "release") + stubRoot := filepath.Join(root, "stubs") + writeZip(t, filepath.Join(releaseDir, "dws-skills.zip"), map[string]string{ + "multi/dingtalk-event/SKILL.md": "new event\n", + "multi/dingtalk-shared/SKILL.md": "new shared\n", + "multi/dingtalk-misc/SKILL.md": "new misc\n", + "mono/SKILL.md": "new mono\n", + }) + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Repeat("0", 64)+" dws-skills.zip\n"), 0o644) + writeFakeCurl(t, filepath.Join(stubRoot, "curl")) + existing := filepath.Join(home, ".agents", "skills", "dingtalk-event", "SKILL.md") + mustWriteFile(t, existing, []byte("old event stays\n"), 0o644) + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-event.sh")) + if err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", scriptPath) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubRoot+":"+os.Getenv("PATH"), + "EVENT_VERSION=v1.0.51", + "DWS_SKILLS_ONLY=1", + "FAKE_RELEASE_DIR="+releaseDir, + "FAKE_ASSET_NAME=unused", + ) + output, runErr := cmd.CombinedOutput() + if runErr == nil || !strings.Contains(string(output), "checksum mismatch") { + t.Fatalf("checksum mismatch result = %v\n%s", runErr, output) + } + if got, err := os.ReadFile(existing); err != nil || string(got) != "old event stays\n" { + t.Fatalf("existing Skill after checksum mismatch = %q, %v", got, err) + } +} + func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { t.Parallel() if runtime.GOOS == "windows" { @@ -534,6 +1887,7 @@ func TestInstallEventScriptNoSkillsOnlyInstallsBinary(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) for _, name := range []string{"dingtalk-event", "dingtalk-shared", "dingtalk-misc"} { mustWriteFile(t, filepath.Join(fakeHome, ".agents", "skills", name, "SKILL.md"), []byte("keep "+name+"\n"), 0o644) @@ -590,6 +1944,7 @@ func TestInstallEventScriptDefaultsToLatestStableRelease(t *testing.T) { writeTarGz(t, filepath.Join(releaseDir, assetName), map[string]string{ "dws": "fake-event-binary\n", }) + writeInstallerFixtureChecksums(t, releaseDir) writeFakeCurl(t, filepath.Join(stubRoot, "curl")) writeFakeGH(t, filepath.Join(stubRoot, "gh"), "v1.0.51") @@ -930,12 +2285,17 @@ func TestInstallScriptSourceModeDefaultMultiInstall(t *testing.T) { } } // Removals must be reversible: the displaced dirs land under - // ~/.dws/skill-backups/ instead of being destroyed. - if findSkillBackup(fixture.fakeHome, "dws", "old mono\n") == "" { + // ~/.dws/skill-backups/ instead of being destroyed, each inside a stamp + // root stamped with the ownership marker. + if got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n"); got == "" { t.Errorf("old mono dws/ should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } - if findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n") == "" { + if got := findSkillBackup(fixture.fakeHome, "dingtalk-stale", "stale\n"); got == "" { t.Errorf("stale dingtalk-* should be backed up under .dws/skill-backups, output:\n%s", output) + } else { + assertSkillBackupMarker(t, filepath.Dir(got)) } if _, err := os.Stat(filepath.Join(base, "other-skill", "SKILL.md")); err != nil { t.Errorf("non-DWS skill must be preserved: %v", err) @@ -1067,6 +2427,21 @@ func TestInstallScriptSourceModeMonoMultiMonoExclusion(t *testing.T) { // directory named name whose SKILL.md equals content, returning its path // ("" when absent). The layout is // with // optional -N collision suffixes on the stamp. +func findSkillBackupContent(fakeHome, content string) string { + root := filepath.Join(fakeHome, ".dws", "skill-backups") + var found string + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || found != "" || info.IsDir() || info.Name() != "SKILL.md" { + return nil + } + if data, err := os.ReadFile(p); err == nil && string(data) == content { + found = p + } + return nil + }) + return found +} + func findSkillBackup(fakeHome, name, content string) string { root := filepath.Join(fakeHome, ".dws", "skill-backups") var found string @@ -1082,6 +2457,73 @@ func findSkillBackup(fakeHome, name, content string) string { return found } +// assertSkillBackupMarker pins the ownership contract shared with the Go core: +// every stamp root a shell installer creates carries .dws-skill-backup with +// exactly "dws skill backup v1\n", because pruning only ever removes +// stamp-shaped roots with those exact bytes. +func assertSkillBackupMarker(t *testing.T, stampRoot string) { + t.Helper() + data, err := os.ReadFile(filepath.Join(stampRoot, skillBackupMarkerFile)) + if err != nil { + t.Fatalf("backup ownership marker missing in %s: %v", stampRoot, err) + } + if string(data) != skillBackupMarkerBody { + t.Fatalf("backup ownership marker in %s = %q, want %q", stampRoot, data, skillBackupMarkerBody) + } +} + +// TestInstallScriptPrunePreservesUnmarkedStampDirs runs the real install.sh +// (whose main ends with prune_skill_backups) against a HOME whose backup root +// already holds a full marked history plus one unmarked stamp-shaped +// directory. The end-of-run prune must remove the marked excess while the +// unmarked directory — foreign data without the ownership marker — is neither +// deleted nor counted toward the keep limit, and the stamp this run creates +// must carry the exact marker bytes. +func TestInstallScriptPrunePreservesUnmarkedStampDirs(t *testing.T) { + t.Parallel() + + fixture := newInstallSourceFixture(t) + installDir := filepath.Join(fixture.root, "bin") + backupRoot := filepath.Join(fixture.fakeHome, ".dws", "skill-backups") + + // Oldest on purpose: without the marker check this directory would be the + // first pruning candidate and would inflate the excess count. + unmarked := filepath.Join(backupRoot, "20200101-000000") + mustWriteFile(t, filepath.Join(unmarked, "user-data.txt"), []byte("foreign\n"), 0o644) + for i := 1; i <= 6; i++ { + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), "skill", "SKILL.md"), []byte("old batch\n"), 0o644) + mustWriteFile(t, filepath.Join(backupRoot, oldStampName(i), skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) + } + seedAgentHome(t, fixture.fakeHome, "dws", "old mono\n") + + runInstallScript(t, fixture.scriptPath, fixture.envWithSkillMode("mono", + "DWS_INSTALL_DIR="+installDir, + "DWS_NO_SKILLS=0", + )) + + // Six marked batches plus this run's one exceed the keep limit of five, + // so exactly the two oldest marked batches are excess and must be gone — + // proving the prune actually ran. + for _, gone := range []string{oldStampName(1), oldStampName(2)} { + if _, err := os.Stat(filepath.Join(backupRoot, gone)); !os.IsNotExist(err) { + t.Fatalf("marked excess stamp %s should have been pruned, stat err=%v", gone, err) + } + } + if _, err := os.Stat(filepath.Join(backupRoot, oldStampName(3))); err != nil { + t.Fatalf("marked keep-range stamp should survive the prune: %v", err) + } + // The unmarked directory is never counted and never removed, even though + // it sorts before every pruned batch. + if data, err := os.ReadFile(filepath.Join(unmarked, "user-data.txt")); err != nil || string(data) != "foreign\n" { + t.Fatalf("unmarked stamp-shaped directory must be preserved untouched: data=%q err=%v", data, err) + } + got := findSkillBackup(fixture.fakeHome, "dws", "old mono\n") + if got == "" { + t.Fatal("this run's dws backup is missing after the prune") + } + assertSkillBackupMarker(t, filepath.Dir(got)) +} + // TestInstallScriptBackupFailureKeepsOriginalDir pins the fail-safe contract: // when the backup destination cannot be created (skill-backups pre-created as // a regular file), the installer must keep every pre-existing skill directory @@ -1189,6 +2631,82 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" } } +func TestInstallSkillsShellMvCrossFilesystemBackupAndRestore(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("real cross-filesystem mv fixture uses Linux /dev/shm") + } + sharedMemoryRoot, err := os.MkdirTemp("/dev/shm", "dws-shell-exdev-") + if err != nil { + t.Skipf("/dev/shm is unavailable: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(sharedMemoryRoot) }) + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatal("install-skills.sh final main invocation not found") + } + library := filepath.Join(t.TempDir(), "install-skills-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + source := filepath.Join(t.TempDir(), "dingtalk-chat") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + probe := filepath.Join(sharedMemoryRoot, "rename-probe") + if err := os.Rename(source, probe); err == nil { + if restoreErr := os.Rename(probe, source); restoreErr != nil { + t.Fatal(restoreErr) + } + t.Skip("/dev/shm and the test temp directory share a filesystem") + } else if !strings.Contains(strings.ToLower(err.Error()), "cross-device") { + t.Skipf("could not establish a cross-filesystem fixture: %v", err) + } + + home := filepath.Join(sharedMemoryRoot, "home") + manifest := filepath.Join(sharedMemoryRoot, "backups.manifest") + harness := `. "$DWS_TEST_LIBRARY" +backup_and_remove_skill_dir "$DWS_TEST_SOURCE" +backup=$DWS_LAST_SKILL_BACKUP +[ -n "$backup" ] && [ -d "$backup" ] && [ ! -e "$DWS_TEST_SOURCE" ] +printf '%s\n%s\n' "$DWS_TEST_SOURCE" "$backup" > "$DWS_TEST_MANIFEST" +restore_multi_skill_set /dev/null "$DWS_TEST_MANIFEST" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_MANIFEST="+manifest, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("cross-filesystem shell backup/restore failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored shell content = %q, %v", got, err) + } + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(source, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("shell %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(source, name)); err != nil || got != want { + t.Fatalf("shell %s target = %q, %v; want %q", name, got, err, want) + } + } +} + func TestInstallSkillsShellPreservesUnregisteredDingtalkSkill(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-skills.sh")) if err != nil { @@ -1242,7 +2760,7 @@ install_multi_skills_to_root "$DWS_TEST_MULTI" "$DWS_TEST_ROOT" assertSkillProvenance(t, home, filepath.Join(base, "dingtalk-test"), "dingtalk-test", "install-skills.sh") } -func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1277,11 +2795,11 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("%s Codex-root harness failed: %v\n%s", scriptName, err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical Codex Skill missing: %v", scriptName, err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } for _, duplicate := range []string{ - filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md"), + filepath.Join(home, ".codex", "skills", "dingtalk-chat"), filepath.Join(home, ".agents", "skills", "dws", "multi", "dingtalk-chat", "SKILL.md"), } { if _, err := os.Stat(duplicate); !os.IsNotExist(err) { @@ -1292,7 +2810,7 @@ func TestInstallerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinksZCodeRootToCanonical(t *testing.T) { for _, scriptName := range []string{"install.sh", "install-skills.sh"} { t.Run(scriptName, func(t *testing.T) { scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) @@ -1328,7 +2846,13 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { t.Fatalf("%s ZCode-root harness failed: %v\n%s", scriptName, err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("%s canonical ZCode Skill missing: %v", scriptName, err) + t.Fatalf("%s linked ZCode Skill missing: %v", scriptName, err) + } + if info, err := os.Lstat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat")); err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s ZCode target is not a canonical link: %#v, %v", scriptName, info, err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("%s canonical Skill missing: %v", scriptName, err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("%s generic duplicate remains: %v", scriptName, err) @@ -1337,7 +2861,591 @@ func TestInstallerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { } } -func TestInstallPowerShellPrefersCodexRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallerShellLinkPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + bundle := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(bundle, "dingtalk-first", "SKILL.md"), []byte("first\n"), 0o644) + mustWriteFile(t, filepath.Join(bundle, "dingtalk-second", "SKILL.md"), []byte("second\n"), 0o644) + + // Publication creates the link directly at the destination with + // `ln -s` (symlink(2) refuses an occupied path atomically; BusyBox + // has no `ln -P`), and multi mode requires the bundle source so + // that only bundle skills are linked. Inject the race on that + // creation. + harness := `. "$DWS_TEST_LIBRARY" +ln() { + if [ "$#" -eq 3 ] && [ "$1" = "-s" ] && [ "$3" = "$DWS_TEST_SECOND" ]; then + rm -f "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" "$DWS_TEST_SECOND" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/user.txt" + printf '%s\n' second-user-data > "$DWS_TEST_SECOND/user.txt" + fi + command ln "$@" +} +if link_canonical_skills_to_base "$HOME" "$DWS_TEST_BASE" multi "$DWS_TEST_BUNDLE"; then + exit 2 +fi +identity_dest="$DWS_TEST_BASE/identity-link" +identity_anchor="$HOME/identity-anchor" +identity_manifest="$HOME/identity.manifest" +command ln -s ../same-target "$identity_anchor" +command ln -P "$identity_anchor" "$identity_dest" +identity_inode="$(skill_link_inode "$identity_anchor")" +printf '%s\n%s\n%s\n' "$identity_dest" ../same-target "$identity_inode" > "$identity_manifest" +rm -f "$identity_dest" +command ln -s ../same-target "$identity_dest" +if restore_linked_skill_set "$identity_manifest" /dev/null; then + exit 3 +fi +rm -f "$identity_anchor" +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_BUNDLE="+bundle, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not report identity-protected rollback:\n%s", scriptName, output) + } + identityDest := filepath.Join(base, "identity-link") + if target, err := os.Readlink(identityDest); err != nil || target != "../same-target" { + t.Fatalf("%s replacement link was removed: target=%q, err=%v", scriptName, target, err) + } + for path, want := range map[string]string{ + filepath.Join(first, "user.txt"): "first-user-data\n", + filepath.Join(second, "user.txt"): "second-user-data\n", + } { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s concurrent data at %s = %q, err=%v", scriptName, path, got, err) + } + } + for _, dir := range []string{first, second} { + entries, err := os.ReadDir(dir) + if err != nil { + t.Fatalf("%s read concurrent directory %s: %v", scriptName, dir, err) + } + if len(entries) != 1 || entries[0].Name() != "user.txt" { + t.Fatalf("%s transaction artifacts remain in %s: %v", scriptName, dir, entries) + } + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-link-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +func TestInstallerShellCopyPublicationRacePreservesConcurrentDirectories(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inject the race inside the second publication's child move: the + // first skill has already been published and recorded, so this + // simulates a concurrent writer replacing it and the transaction + // failing afterwards — rollback must verify identity and retain + // the foreign object instead of blind-deleting the path. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + rm -rf "$DWS_TEST_FIRST" + mkdir -p "$DWS_TEST_FIRST" + printf '%s\n' first-user-data > "$DWS_TEST_FIRST/concurrent-user-data.txt" + return 1 + fi + ;; + esac + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s copy race harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip identity-mismatched rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "concurrent-user-data.txt")) + if err != nil || string(got) != "first-user-data\n" { + t.Fatalf("%s concurrent directory was deleted or modified: %q, %v", scriptName, string(got), err) + } + entries, err := os.ReadDir(first) + if err != nil || len(entries) != 1 || entries[0].Name() != "concurrent-user-data.txt" { + t.Fatalf("%s foreign directory contents changed: %v, %v", scriptName, entries, err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +// TestInstallerShellRollbackPreservesInPlaceEditedSkill pins the P1 contract: +// a published Skill directory whose file contents were edited in place (same +// directory inode, same child names) must not be deleted by the rollback of a +// later failed publication. The recorded identity carries a recursive content +// digest, so the edit makes the dest provably not-this-transaction's object. +func TestInstallerShellRollbackPreservesInPlaceEditedSkill(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + base := filepath.Join(home, ".zcode", "skills") + first := filepath.Join(base, "dingtalk-first") + second := filepath.Join(base, "dingtalk-second") + mustWriteFile(t, filepath.Join(first, "SKILL.md"), []byte("old first\n"), 0o644) + mustWriteFile(t, filepath.Join(second, "SKILL.md"), []byte("old second\n"), 0o644) + src := filepath.Join(t.TempDir(), "multi") + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "SKILL.md"), []byte("new first\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-first", "references", "guide.md"), []byte("guide\n"), 0o644) + mustWriteFile(t, filepath.Join(src, "dingtalk-second", "SKILL.md"), []byte("new second\n"), 0o644) + + // Inside the second publication's child move — after the first + // skill has been published and its identity recorded — edit a file + // inside the first destination in place (append keeps the inode, + // directory inode and child names are untouched), then fail the + // transaction. Rollback must detect the content drift through the + // recorded digest and leave the user's edit alone. + harness := `. "$DWS_TEST_LIBRARY" +mv() { + case "$1" in + */.dws-multi-set.*) + if [ "$2" = "$DWS_TEST_SECOND/" ]; then + printf 'user-edit\n' >> "$DWS_TEST_FIRST/references/guide.md" + return 1 + fi + ;; + esac + command mv "$@" +} +if _install_multi_to_base "$DWS_TEST_SRC" "$DWS_TEST_BASE" "$HOME" ".zcode/skills"; then + exit 2 +fi +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_BASE="+base, + "DWS_TEST_SRC="+src, + "DWS_TEST_FIRST="+first, + "DWS_TEST_SECOND="+second, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s in-place edit harness failed: %v\n%s", scriptName, err, output) + } + if !strings.Contains(string(output), "跳过回滚已被并发修改的 Skill 路径: "+first) { + t.Fatalf("%s did not skip in-place-edited rollback:\n%s", scriptName, output) + } + got, err := os.ReadFile(filepath.Join(first, "references", "guide.md")) + if err != nil || string(got) != "guide\nuser-edit\n" { + t.Fatalf("%s in-place user edit was lost: %q, %v", scriptName, string(got), err) + } + if restored, err := os.ReadFile(filepath.Join(second, "SKILL.md")); err != nil || string(restored) != "old second\n" { + t.Fatalf("%s second skill was not restored from backup: %q, %v", scriptName, string(restored), err) + } + if matches, err := filepath.Glob(filepath.Join(base, ".dws-multi-set.*")); err != nil || len(matches) != 0 { + t.Fatalf("%s staging leftovers = %v, err=%v", scriptName, matches, err) + } + }) + } +} + +// TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling pins +// the review blocker on the shared stamp root: the backup collision loop +// tests the payload path, so two backups in the same stamp second land in +// ONE stamp root. When the second call fails to write the ownership marker, +// the old `rm -rf` cleanup destroyed the first, already completed sibling +// payload whose original directory had already been moved away. The cleanup +// must be non-recursive and may only drop a stamp root the failing call +// itself created (marker file plus empty root; rmdir refuses non-empty). +func TestInstallerShellBackupMarkerWriteFailurePreservesSameStampSibling(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + // Both scenarios rely on permission-bit enforcement (unwritable marker + // file, unwritable fresh root), which root bypasses entirely. + if os.Geteuid() == 0 { + t.Skip("running as root ignores permission bits, the marker write would succeed") + } + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + backupFn := "backup_and_remove_skill_dir" + emitsMarkerWarning := true + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + backupFn = "backup_skill_dir" + emitsMarkerWarning = false + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + // Pin the stamp: a stub `date` always prints the same UTC second, + // so consecutive backups deterministically share one stamp root. + const stamp = "20260818-120000" + stubDir := filepath.Join(t.TempDir(), "bin") + mustWriteFile(t, filepath.Join(stubDir, "date"), []byte("#!/bin/sh\nprintf '"+stamp+"\\n'\n"), 0o755) + + t.Run("same-stamp sibling payload survives marker write failure", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victimA := filepath.Join(home, ".agents", "skills", "skill-a") + victimB := filepath.Join(home, ".agents", "skills", "skill-b") + mustWriteFile(t, filepath.Join(victimA, "SKILL.md"), []byte("payload A\n"), 0o644) + mustWriteFile(t, filepath.Join(victimB, "SKILL.md"), []byte("victim B\n"), 0o644) + + // The first backup succeeds and moves victim A into the stamp + // root; the marker is then made unwritable so the second, + // same-second backup fails exactly at the marker write. + harness := `. "$DWS_TEST_LIBRARY" +if ` + backupFn + ` "$DWS_TEST_VICTIM_A"; then :; else exit 10; fi +chmod 000 "$DWS_TEST_ROOT/` + skillBackupMarkerFile + `" +if ` + backupFn + ` "$DWS_TEST_VICTIM_B"; then + exit 11 +fi +[ -d "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM_B/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM_A="+victimA, + "DWS_TEST_VICTIM_B="+victimB, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s sibling marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if emitsMarkerWarning && !strings.Contains(string(output), "无法写入备份所有权标记") { + t.Fatalf("%s did not report the marker write failure:\n%s", scriptName, output) + } + if _, err := os.Lstat(victimA); !os.IsNotExist(err) { + t.Fatalf("%s first victim must have been moved into the stamp root: %v", scriptName, err) + } + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("%s stamp root must survive the failed marker write: %v", scriptName, err) + } + var payloadDirs []string + for _, entry := range entries { + if entry.Name() == skillBackupMarkerFile { + continue + } + if !entry.IsDir() { + t.Fatalf("%s stamp root carries an unexpected non-marker entry %s", scriptName, entry.Name()) + } + payloadDirs = append(payloadDirs, entry.Name()) + } + if len(payloadDirs) != 1 { + t.Fatalf("%s stamp root must retain exactly the first payload, got %v", scriptName, payloadDirs) + } + got, err := os.ReadFile(filepath.Join(root, payloadDirs[0], "SKILL.md")) + if err != nil || string(got) != "payload A\n" { + t.Fatalf("%s completed sibling payload was destroyed: %q, %v", scriptName, got, err) + } + if got, err := os.ReadFile(filepath.Join(victimB, "SKILL.md")); err != nil || string(got) != "victim B\n" { + t.Fatalf("%s second victim must stay at its original path: %q, %v", scriptName, got, err) + } + }) + + t.Run("fresh stamp root is dropped non-recursively", func(t *testing.T) { + home := t.TempDir() + root := filepath.Join(home, ".dws", "skill-backups", stamp) + victim := filepath.Join(home, ".agents", "skills", "skill-fresh") + mustWriteFile(t, filepath.Join(victim, "SKILL.md"), []byte("fresh victim\n"), 0o644) + + // The failing call itself created the root, so the cleanup + // must drop it again: mkdir is intercepted to hand back a + // non-writable root, failing the marker write while the root + // stays removable through its writable parent directory. + harness := `. "$DWS_TEST_LIBRARY" +mkdir() { + command mkdir "$@" + _mk_rc=$? + if [ "${1:-}" = "-p" ] && [ "${2:-}" = "$DWS_TEST_ROOT" ]; then + chmod 0555 "$DWS_TEST_ROOT" + fi + return "$_mk_rc" +} +if ` + backupFn + ` "$DWS_TEST_VICTIM"; then + exit 11 +fi +[ ! -e "$DWS_TEST_ROOT" ] || exit 12 +[ -f "$DWS_TEST_VICTIM/SKILL.md" ] || exit 13 +` + cmd := exec.Command("sh", "-c", harness) + cmd.Env = append(os.Environ(), + "HOME="+home, + "PATH="+stubDir+":"+os.Getenv("PATH"), + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_ROOT="+root, + "DWS_TEST_VICTIM="+victim, + ) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s fresh marker-failure harness failed: %v\n%s", scriptName, err, output) + } + if _, err := os.Lstat(root); !os.IsNotExist(err) { + t.Fatalf("%s fresh stamp root must be removed again: %v", scriptName, err) + } + if got, err := os.ReadFile(filepath.Join(victim, "SKILL.md")); err != nil || string(got) != "fresh victim\n" { + t.Fatalf("%s victim must stay untouched at its original path: %q, %v", scriptName, got, err) + } + }) + }) + } +} + +func TestInstallerShellUsesUpstreamXDGAndCustomAgentRoots(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + tmp := t.TempDir() + xdg := filepath.Join(tmp, "xdg config") + autohand := filepath.Join(tmp, "autohand home") + codex := filepath.Join(tmp, "codex home") + source := filepath.Join(tmp, "multi") + mustWriteFile(t, filepath.Join(source, "dingtalk-chat", "SKILL.md"), []byte("new chat\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "goose", "config.yaml"), []byte("enabled: true\n"), 0o644) + mustWriteFile(t, filepath.Join(xdg, "agents", "skills", "dingtalk-chat", "SKILL.md"), []byte("old amp copy\n"), 0o644) + mustWriteFile(t, filepath.Join(autohand, "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "config.toml"), []byte("model=test\n"), 0o644) + mustWriteFile(t, filepath.Join(codex, "skills", "dingtalk-chat", "SKILL.md"), []byte("old codex copy\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".qoderwork", "config.json"), []byte("{}\n"), 0o644) + mustWriteFile(t, filepath.Join(home, ".amp", "skills", "dingtalk-chat", "SKILL.md"), []byte("old DWS path\n"), 0o644) + + installCall := `install_multi_skills_to_homes "$DWS_TEST_SOURCE"` + if scriptName == "install-skills.sh" { + installCall = `install_multi_skills_to_root "$DWS_TEST_SOURCE" "$HOME"` + } + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +`+installCall+` +`) + cmd.Env = append(os.Environ(), + "HOME="+home, + "DWS_TEST_LIBRARY="+library, + "DWS_TEST_SOURCE="+source, + "XDG_CONFIG_HOME="+xdg, + "AUTOHAND_HOME="+autohand, + "CODEX_HOME="+codex, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s upstream-root harness failed: %v\n%s", scriptName, err, output) + } + + for _, linked := range []string{ + filepath.Join(xdg, "goose", "skills", "dingtalk-chat"), + filepath.Join(autohand, "skills", "dingtalk-chat"), + filepath.Join(home, ".qoderwork", "skills", "dingtalk-chat"), + } { + info, err := os.Lstat(linked) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s is not a canonical symlink: %#v, %v", linked, info, err) + } + target, err := os.Readlink(linked) + if err != nil || filepath.IsAbs(target) { + t.Fatalf("%s link must be relative: target=%q err=%v", linked, target, err) + } + } + for _, retired := range []string{ + filepath.Join(xdg, "agents", "skills", "dingtalk-chat"), + filepath.Join(codex, "skills", "dingtalk-chat"), + filepath.Join(home, ".amp", "skills", "dingtalk-chat"), + } { + if _, err := os.Lstat(retired); !os.IsNotExist(err) { + t.Fatalf("%s universal/legacy duplicate remains: %v", retired, err) + } + } + }) + } +} + +func TestInstallerShellPinsCompleteUpstreamAgentRegistry(t *testing.T) { + for _, scriptName := range []string{"install.sh", "install-skills.sh"} { + t.Run(scriptName, func(t *testing.T) { + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY" +upstream_agent_registry +`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("%s registry failed: %v\n%s", scriptName, err, output) + } + lines := strings.Fields(strings.TrimSpace(string(output))) + if len(lines) != 76 { + t.Fatalf("%s registry has %d Agents, want 76", scriptName, len(lines)) + } + ids := map[string]bool{} + roots := map[string]bool{} + universal, nonUniversal, noGlobal, canonicalDirect := 0, 0, 0, 0 + for _, line := range lines { + parts := strings.Split(line, "|") + if len(parts) != 3 || ids[parts[0]] { + t.Fatalf("%s invalid or duplicate registry record %q", scriptName, line) + } + ids[parts[0]] = true + switch parts[1] { + case "U": + universal++ + case "N": + nonUniversal++ + default: + t.Fatalf("%s invalid classification in %q", scriptName, line) + } + switch parts[2] { + case "-": + noGlobal++ + case ".agents/skills": + canonicalDirect++ + default: + roots[parts[2]] = true + } + } + if universal != 19 || nonUniversal != 57 || noGlobal != 2 || canonicalDirect != 6 || len(roots) != 65 { + t.Fatalf("%s registry classification U=%d N=%d no-global=%d canonical=%d roots=%d", scriptName, universal, nonUniversal, noGlobal, canonicalDirect, len(roots)) + } + }) + } +} + +func TestInstallPowerShellUsesCanonicalRootWithoutCodexDuplicate(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1378,15 +3486,18 @@ exit 0 if output, err := cmd.CombinedOutput(); err != nil { t.Fatalf("PowerShell Codex-root harness failed: %v\n%s", err, output) } - if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical Codex Skill missing: %v", err) + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) + } + if _, err := os.Lstat(filepath.Join(home, ".codex", "skills", "dingtalk-chat")); !os.IsNotExist(err) { + t.Fatalf("PowerShell Codex duplicate remains: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) } } -func TestInstallPowerShellPrefersZCodeRootWithoutGenericDuplicate(t *testing.T) { +func TestInstallPowerShellLinksZCodeRootToCanonical(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { if runtime.GOOS == "windows" { @@ -1428,7 +3539,10 @@ exit 0 t.Fatalf("PowerShell ZCode-root harness failed: %v\n%s", err, output) } if _, err := os.Stat(filepath.Join(home, ".zcode", "skills", "dingtalk-chat", "SKILL.md")); err != nil { - t.Fatalf("PowerShell canonical ZCode Skill missing: %v", err) + t.Fatalf("PowerShell linked ZCode Skill missing: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-chat", "SKILL.md")); err != nil { + t.Fatalf("PowerShell canonical Skill missing: %v", err) } if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws")); !os.IsNotExist(err) { t.Fatalf("PowerShell generic duplicate remains: %v", err) @@ -1787,7 +3901,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-multi-set.*/dingtalk-second) return 1 ;; + */.dws-multi-set.*/dingtalk-second/*) return 1 ;; esac fi command mv "$@" @@ -1921,7 +4035,7 @@ mv() { fi if [ "$DWS_TEST_FAILURE_KIND" = publish ]; then case "$1" in - */.dws-mono-set.*/dws) return 1 ;; + */.dws-mono-set.*/dws/*) return 1 ;; esac fi command mv "$@" @@ -2037,6 +4151,371 @@ exit 0 } } +func TestInstallPowerShellCrossDeviceMoveContract(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "# ── Main") + if cut < 0 { + t.Fatal("install.ps1 main section not found") + } + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + + for _, phase := range []string{"success", "copy", "verify", "remove", "permission", "dest-verify"} { + phase := phase + t.Run(phase, func(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "skill") + destination := filepath.Join(home, ".dws", "skill-backups", "stamp", "skill") + restored := filepath.Join(root, "external", "restored-skill") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old skill\n"), 0o640) + linksCreated := false + if phase == "success" { + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + } + + harness := prefix + ` +$script:OriginalCopySkillPathLexically = ${function:Copy-SkillPathLexically} +$script:OriginalAssertSkillPathCopy = ${function:Assert-SkillPathCopy} +$script:OriginalRemoveSkillPathLexically = ${function:Remove-SkillPathLexically} +function New-CrossDeviceError { return [System.IO.IOException]::new("injected cross-device rename", -2147024879) } +function Move-SkillPath { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "permission" -and $Source -eq $env:DWS_TEST_SOURCE) { + throw [System.UnauthorizedAccessException]::new("permission denied") + } + if (($Source -eq $env:DWS_TEST_SOURCE -and $Destination -eq $env:DWS_TEST_DESTINATION) -or + ($Source -eq $env:DWS_TEST_DESTINATION -and $Destination -eq $env:DWS_TEST_RESTORED)) { + throw (New-CrossDeviceError) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +function Copy-SkillPathLexically { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "copy") { throw "copy failed" } + if ($env:DWS_TEST_PHASE -eq "permission") { + [System.IO.File]::WriteAllText($env:DWS_TEST_COPY_MARKER, "called") + } + & $script:OriginalCopySkillPathLexically -Source $Source -Destination $Destination +} +function Assert-SkillPathCopy { + param([string]$Source, [string]$Destination) + if ($env:DWS_TEST_PHASE -eq "verify") { throw "verify failed" } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $Destination -eq $env:DWS_TEST_DESTINATION) { throw "dest-verify failed" } + & $script:OriginalAssertSkillPathCopy -Source $Source -Destination $Destination +} +function Remove-SkillPathLexically { + param([string]$Path) + if ($env:DWS_TEST_PHASE -eq "remove" -and $Path -eq $env:DWS_TEST_SOURCE) { throw "remove failed" } + & $script:OriginalRemoveSkillPathLexically -Path $Path +} +try { + Move-SkillPathRecoverably -Source $env:DWS_TEST_SOURCE -Destination $env:DWS_TEST_DESTINATION + if ($env:DWS_TEST_PHASE -ne "success") { exit 2 } + Move-SkillPathRecoverably -Source $env:DWS_TEST_DESTINATION -Destination $env:DWS_TEST_RESTORED + exit 0 +} catch { + if ($env:DWS_TEST_PHASE -eq "success") { Write-Error $_; exit 3 } + if ($env:DWS_TEST_PHASE -eq "copy" -and $_ -notmatch "copy failed") { Write-Error $_; exit 4 } + if ($env:DWS_TEST_PHASE -eq "verify" -and $_ -notmatch "verify failed") { Write-Error $_; exit 5 } + if ($env:DWS_TEST_PHASE -eq "remove" -and $_ -notmatch "均保留") { Write-Error $_; exit 6 } + if ($env:DWS_TEST_PHASE -eq "permission" -and $_ -notmatch "permission denied") { Write-Error $_; exit 7 } + if ($env:DWS_TEST_PHASE -eq "dest-verify" -and $_ -notmatch "目标已撤回|dest-verify failed|状态不确定") { Write-Error $_; exit 8 } + exit 0 +} +` + harnessPath := filepath.Join(t.TempDir(), "install-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + copyMarker := filepath.Join(root, "copy-called") + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + "DWS_TEST_RESTORED="+restored, + "DWS_TEST_COPY_MARKER="+copyMarker, + "DWS_TEST_PHASE="+phase, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell %s EXDEV harness failed: %v\n%s", phase, err, output) + } + + if phase == "success" { + if got, err := os.ReadFile(filepath.Join(restored, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("restored content = %q, %v", got, err) + } + if _, err := os.Lstat(source); !os.IsNotExist(err) { + t.Fatalf("source must be removed: %v", err) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("backup must be consumed by rollback: %v", err) + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(restored, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("%s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(restored, name)); err != nil || got != want { + t.Fatalf("%s target = %q, %v; want %q", name, got, err, want) + } + } + } + return + } + + if got, err := os.ReadFile(filepath.Join(source, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("source after %s failure = %q, %v", phase, got, err) + } + if phase == "remove" { + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old skill\n" { + t.Fatalf("published backup after remove failure = %q, %v", got, err) + } + } else if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("destination after %s failure = %v", phase, err) + } + if phase == "permission" { + if _, err := os.Stat(copyMarker); !os.IsNotExist(err) { + t.Fatalf("permission error must not trigger copy fallback: %v", err) + } + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".skill.cross-device-*")); err != nil || len(matches) != 0 { + t.Fatalf("PowerShell %s staging leftovers = %v, %v", phase, matches, err) + } + }) + } +} + +func TestInstallDevappPowerShellCrossDeviceBackup(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("old dev skill\n"), 0o640) + linksCreated := false + if err := os.Symlink("SKILL.md", filepath.Join(source, "skill-link")); err == nil { + if err := os.Symlink("missing.md", filepath.Join(source, "dangling-link")); err != nil { + t.Fatal(err) + } + linksCreated = true + } else if runtime.GOOS != "windows" { + t.Fatal(err) + } + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -eq $env:DWS_TEST_SOURCE) { + throw [System.IO.IOException]::new("injected cross-device rename", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +Backup-DevSkill $env:DWS_TEST_SOURCE +if (Test-PathLexically $env:DWS_TEST_SOURCE) { exit 2 } +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-cross-device-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), "DWS_TEST_HOME="+home, "DWS_TEST_SOURCE="+source) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp EXDEV backup failed: %v\n%s", err, output) + } + backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n") + if backup == "" { + t.Fatal("PowerShell devapp backup missing") + } + if linksCreated { + for name, want := range map[string]string{"skill-link": "SKILL.md", "dangling-link": "missing.md"} { + info, err := os.Lstat(filepath.Join(backup, name)) + if err != nil || info.Mode()&os.ModeSymlink == 0 { + t.Fatalf("devapp %s must remain a symlink: %v, %v", name, info, err) + } + if got, err := os.Readlink(filepath.Join(backup, name)); err != nil || got != want { + t.Fatalf("devapp %s target = %q, %v; want %q", name, got, err, want) + } + } + } +} + +func TestInstallDevappPowerShellPublishFailureRestoresAcrossDevice(t *testing.T) { + pwsh, err := exec.LookPath("pwsh") + if err != nil { + if runtime.GOOS == "windows" { + pwsh, err = exec.LookPath("powershell") + } + if err != nil { + t.Skip("PowerShell is not available") + } + } + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + prefix += ` +function Move-DevSkillPath([string]$Source, [string]$Destination) { + if ($Source -match "[\\/].dws-dev-copy-[^\\/]+[\\/]payload$") { throw "injected publish failure" } + if ($Source -match "[\\/]skill-backups[\\/]" -and $Destination -eq $env:DWS_TEST_DESTINATION) { + throw [System.IO.IOException]::new("injected cross-device rollback", -2147024879) + } + Microsoft.PowerShell.Management\Move-Item -LiteralPath $Source -Destination $Destination -ErrorAction Stop +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if ($_ -notmatch "injected publish failure") { Write-Error $_; exit 3 } +} +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publish-rollback-harness.ps1") + mustWriteFile(t, harnessPath, []byte(prefix), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publish rollback failed: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "SKILL.md")); err != nil || string(got) != "old dev skill\n" { + t.Fatalf("restored devapp content = %q, %v", got, err) + } + if matches, err := filepath.Glob(filepath.Join(filepath.Dir(destination), ".dws-dev-copy-*")); err != nil || len(matches) != 0 { + t.Fatalf("devapp publish staging leftovers = %v, %v", matches, err) + } +} + +func TestInstallDevappPowerShellPublicationRacePreservesConcurrentDirectory(t *testing.T) { + powerShellNames := []string{"pwsh"} + if runtime.GOOS == "windows" { + powerShellNames = []string{"powershell", "pwsh"} + } + pwsh := "" + for _, name := range powerShellNames { + if candidate, err := exec.LookPath(name); err == nil { + pwsh = candidate + break + } + } + if pwsh == "" { + t.Skip("PowerShell is not available") + } + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "install-devapp.ps1")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.Index(string(data), "# Read the releases list") + if cut < 0 { + t.Fatal("install-devapp.ps1 release section not found") + } + root := t.TempDir() + home := filepath.Join(root, "home") + source := filepath.Join(root, "bundle", "dingtalk-misc") + destination := filepath.Join(root, "external", "dingtalk-misc") + mustWriteFile(t, filepath.Join(source, "SKILL.md"), []byte("new dev skill\n"), 0o640) + mustWriteFile(t, filepath.Join(destination, "SKILL.md"), []byte("old dev skill\n"), 0o644) + + prefix := strings.ReplaceAll(string(data[:cut]), "$HOME", "$env:DWS_TEST_HOME") + harness := prefix + ` +$script:OriginalBackupDevSkill = ${function:Backup-DevSkill} +function Backup-DevSkill([string]$path, [ref]$BackupPath) { + & $script:OriginalBackupDevSkill $path $BackupPath + # Simulate another installer recreating the destination after the old + # content was backed up but before this transaction publishes its stage. + New-Item -ItemType Directory -Path $path -Force -ErrorAction Stop | Out-Null + Set-Content -LiteralPath (Join-Path $path 'concurrent-user-data.txt') -Value 'keep' +} +try { + Publish-DevSkillCopy $env:DWS_TEST_SOURCE $env:DWS_TEST_DESTINATION + exit 2 +} catch { + if (!$_.Exception.Data['DWSRollbackFailed']) { Write-Error $_; exit 3 } +} +if (!(Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'concurrent-user-data.txt') -PathType Leaf)) { exit 4 } +if (Test-Path -LiteralPath (Join-Path $env:DWS_TEST_DESTINATION 'payload')) { exit 5 } +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "install-devapp-publication-race.ps1") + mustWriteFile(t, harnessPath, []byte(harness), 0o644) + cmd := exec.Command(pwsh, "-NoProfile", "-NonInteractive", "-File", harnessPath) + cmd.Env = append(os.Environ(), + "DWS_TEST_HOME="+home, + "DWS_TEST_SOURCE="+source, + "DWS_TEST_DESTINATION="+destination, + ) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("PowerShell devapp publication race must retain concurrent directory: %v\n%s", err, output) + } + if got, err := os.ReadFile(filepath.Join(destination, "concurrent-user-data.txt")); err != nil || strings.TrimSpace(string(got)) != "keep" { + t.Fatalf("concurrent devapp data = %q, %v", got, err) + } + if backup := findSkillBackup(home, "dingtalk-misc", "old dev skill\n"); backup == "" { + t.Fatal("original devapp backup must be retained after publication race") + } +} + func TestInstallPowerShellMonoTransactionFailuresRestoreOldSet(t *testing.T) { pwsh, err := exec.LookPath("pwsh") if err != nil { @@ -2257,12 +4736,34 @@ done case "$url" in *"/${FAKE_ASSET_NAME}") cp "$FAKE_RELEASE_DIR/$FAKE_ASSET_NAME" "$out" ;; *"/dws-skills.zip") cp "$FAKE_RELEASE_DIR/dws-skills.zip" "$out" ;; + *"/checksums.txt") cp "$FAKE_RELEASE_DIR/checksums.txt" "$out" ;; *) echo "fake curl: unexpected URL $url" >&2; exit 1 ;; esac ` mustWriteFile(t, path, []byte(script), 0o755) } +func writeInstallerFixtureChecksums(t *testing.T, releaseDir string) { + t.Helper() + entries, err := os.ReadDir(releaseDir) + if err != nil { + t.Fatalf("ReadDir(%s) error = %v", releaseDir, err) + } + var lines []string + for _, entry := range entries { + if entry.IsDir() || entry.Name() == "checksums.txt" { + continue + } + data, err := os.ReadFile(filepath.Join(releaseDir, entry.Name())) + if err != nil { + t.Fatalf("ReadFile(%s) error = %v", entry.Name(), err) + } + digest := sha256.Sum256(data) + lines = append(lines, hex.EncodeToString(digest[:])+" "+entry.Name()) + } + mustWriteFile(t, filepath.Join(releaseDir, "checksums.txt"), []byte(strings.Join(lines, "\n")+"\n"), 0o644) +} + func writeFakeGH(t *testing.T, path, version string) { t.Helper() script := "#!/bin/sh\nprintf '%s\\n' '" + version + "'\n" diff --git a/test/scripts/package_script_test.go b/test/scripts/package_script_test.go index fd3746ba..73798dcd 100644 --- a/test/scripts/package_script_test.go +++ b/test/scripts/package_script_test.go @@ -30,6 +30,10 @@ var expectedPackagedSkillTargets = []string{ ".augment/skills/dingtalk-shared", ".cline/skills/dingtalk-shared", ".amp/skills/dingtalk-shared", + ".copilot/skills/dingtalk-shared", + ".config/opencode/skills/dingtalk-shared", + ".config/agents/skills/dingtalk-shared", + ".codeium/windsurf/skills/dingtalk-shared", ".kiro/skills/dingtalk-shared", ".trae/skills/dingtalk-shared", ".openclaw/skills/dingtalk-shared", @@ -70,9 +74,12 @@ func TestPackageManagerVersionVerificationReadsRawBinary(t *testing.T) { t.Fatal("package-manager verifier still requires the version marker to occupy a strings(1) line") } for _, want := range []string{ - "HOME_SPECIFIC_SKILL_BASES=", - `$base/dingtalk-shared/SKILL.md`, - `$base/dingtalk-misc/SKILL.md`, + "UPSTREAM_AGENT_REGISTRY=", + "verify_compatible_skill_base", + `diff -qr "$canonical/$name" "$target"`, + "broken canonical Skill link", + "Skill link does not resolve to canonical source", + "Skill copy fallback differs from canonical source", "unexpected mono Skill layout", `verify_npm_install "$tarball_path" "specific-agent-roots"`, `verify_npm_install "$tarball_path" "generic-fallback"`, @@ -128,11 +135,18 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } verifyCmd := exec.Command("sh", verifierPath, "--npm-only") - verifyCmd.Env = append(os.Environ(), "DWS_PACKAGE_DIST_DIR="+distDir) + hostXDG := filepath.Join(t.TempDir(), "host-xdg") + verifyCmd.Env = replaceTestEnv(os.Environ(), + "DWS_PACKAGE_DIST_DIR", distDir, + "XDG_CONFIG_HOME", hostXDG, + ) output, err := verifyCmd.CombinedOutput() if err != nil { t.Fatalf("verify-package-managers.sh error = %v\noutput:\n%s", err, output) } + if _, err := os.Stat(hostXDG); !os.IsNotExist(err) { + t.Fatalf("package verifier touched inherited XDG_CONFIG_HOME %s: %v", hostXDG, err) + } for _, scenario := range []string{"specific-agent-roots", "generic-fallback"} { if !strings.Contains(string(output), "verifying npm package install ("+scenario+")") { t.Errorf("verifier output is missing %s scenario:\n%s", scenario, output) @@ -140,6 +154,102 @@ func TestPackageManagerVerifierCoversSpecificAndFallbackSkillRoots(t *testing.T) } } +func TestPackageManagerVerifierAcceptsOnlyCorrectLinksOrCopies(t *testing.T) { + t.Parallel() + + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", "release", "verify-package-managers.sh")) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + text := string(data) + cut := strings.Index(text, "\nverify_npm_install()") + if cut < 0 { + t.Fatal("verify-package-managers.sh helper boundary missing") + } + library := filepath.Join(t.TempDir(), "verifier-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + for _, tc := range []struct { + name string + prepare func(t *testing.T, home, base string) + wantOK bool + }{ + { + name: "correct-link", + prepare: func(t *testing.T, _, base string) { + t.Helper() + // The canonical layout publishes RELATIVE links; the verifier now + // rejects absolute targets, so the fixture must match production. + if err := os.Symlink(filepath.Join("..", "..", ".agents", "skills", "dingtalk-shared"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: true, + }, + { + name: "complete-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + }, + wantOK: true, + }, + { + name: "wrong-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + wrong := filepath.Join(home, "wrong", "dingtalk-shared") + mustWriteFile(t, filepath.Join(wrong, "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.Symlink(wrong, filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "broken-link", + prepare: func(t *testing.T, home, base string) { + t.Helper() + if err := os.Symlink(filepath.Join(home, "missing"), filepath.Join(base, "dingtalk-shared")); err != nil { + t.Fatal(err) + } + }, + wantOK: false, + }, + { + name: "different-copy", + prepare: func(t *testing.T, _, base string) { + t.Helper() + mustWriteFile(t, filepath.Join(base, "dingtalk-shared", "SKILL.md"), []byte("different\n"), 0o644) + }, + wantOK: false, + }, + } { + t.Run(tc.name, func(t *testing.T) { + home := t.TempDir() + base := filepath.Join(home, ".claude", "skills") + mustWriteFile(t, filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md"), []byte("canonical\n"), 0o644) + if err := os.MkdirAll(base, 0o755); err != nil { + t.Fatal(err) + } + tc.prepare(t, home, base) + cmd := exec.Command("sh", "-c", `. "$DWS_TEST_LIBRARY"; verify_compatible_skill_base "$DWS_TEST_HOME" "$DWS_TEST_BASE"`) + cmd.Env = append(os.Environ(), "DWS_TEST_LIBRARY="+library, "DWS_TEST_HOME="+home, "DWS_TEST_BASE="+base) + output, err := cmd.CombinedOutput() + if tc.wantOK && err != nil { + t.Fatalf("verifier rejected valid target: %v\n%s", err, output) + } + if !tc.wantOK && err == nil { + t.Fatalf("verifier accepted invalid target\n%s", output) + } + }) + } +} + func seedDistArchive(t *testing.T, path string) { t.Helper() file, err := os.Create(path) @@ -1193,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") @@ -1269,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") diff --git a/test/scripts/release_script_test.go b/test/scripts/release_script_test.go index 6ad03b77..a53c7910 100644 --- a/test/scripts/release_script_test.go +++ b/test/scripts/release_script_test.go @@ -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 { @@ -1699,6 +1895,85 @@ func TestReleasePrepareChangelogRendersAndArchivesPrereleaseFragments(t *testing } } +func TestReleasePrepareChangelogRendersAndArchivesStableFragments(t *testing.T) { + r := newReleaseTestRepo(t) + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755) + releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755) + mustWriteFile(t, filepath.Join(r.root, ".changes", "1236-stable-followup.md"), []byte("---\ncategory: Fixed\n---\n\n- **Stable follow-up** (#1236) — includes a change merged after the beta.\n"), 0o644) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "promotes the sealed `v1.0.1-beta.1` contents to stable") || + !strings.Contains(content, "### Changes since `v1.0.1-beta.1`") || + !strings.Contains(content, "### Fixed") || + !strings.Contains(content, "Stable follow-up") { + t.Fatalf("prepared stable changelog did not render post-beta fragments:\n%s", changelog) + } + name := "1236-stable-followup.md" + if _, err := os.Stat(filepath.Join(r.root, ".changes", name)); !os.IsNotExist(err) { + t.Fatalf("unconsumed stable release fragment %s still present: %v", name, err) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1", name)); err != nil { + t.Fatalf("archived stable release fragment %s missing: %v", name, err) + } +} + +func TestReleasePrepareChangelogStableWithoutFragmentsKeepsTemplate(t *testing.T) { + r := newReleaseTestRepo(t) + releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) + releaseCopyFile(t, r.prepare, filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), 0o755) + releaseCopyFile(t, r.render, filepath.Join(r.root, "scripts", "release", "render-release-fragments.sh"), 0o755) + r.commitAndPush(t, "install stable changelog preparation") + + cmd := exec.Command( + "sh", + filepath.Join(r.root, "scripts", "release", "prepare-changelog.sh"), + "stable", + "v1.0.1", + "--from-beta", + "v1.0.1-beta.1", + ) + cmd.Dir = r.root + cmd.Env = append(os.Environ(), "DWS_RELEASE_DATE=2026-07-11") + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("prepare stable changelog without fragments error = %v\noutput:\n%s", err, output) + } + changelog, err := os.ReadFile(filepath.Join(r.root, "CHANGELOG.md")) + if err != nil { + t.Fatalf("ReadFile(CHANGELOG.md) error = %v", err) + } + content := string(changelog) + if !strings.Contains(content, "## [1.0.1] - 2026-07-11") || + !strings.Contains(content, "TODO: summarize the complete user-visible release") || + strings.Contains(content, "### Changes since") { + t.Fatalf("stable changelog without fragments changed template behavior:\n%s", changelog) + } + if _, err := os.Stat(filepath.Join(r.root, ".changes", "released", "1.0.1")); !os.IsNotExist(err) { + t.Fatalf("stable preparation without fragments created an archive: %v", err) + } +} + func TestReleasePrepareChangelogRejectsInvalidReleaseFragment(t *testing.T) { r := newReleaseTestRepo(t) releaseCopyFile(t, r.lib, filepath.Join(r.root, "scripts", "release", "release-lib.sh"), 0o644) @@ -2631,12 +2906,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 +2944,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) } diff --git a/test/scripts/skill_backup_prune_test.go b/test/scripts/skill_backup_prune_test.go new file mode 100644 index 00000000..75953d3d --- /dev/null +++ b/test/scripts/skill_backup_prune_test.go @@ -0,0 +1,320 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package scripts_test + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" +) + +// shellPruneScriptTargets are the standalone installers that embed their own +// copy of prune_skill_backups. Each must keep every backup stamp the running +// process created, mirroring Go's run-root registry and install.js's +// currentRunBackupRoots, so a migration retiring more than the keep limit +// stays reversible. +var shellPruneScriptTargets = []string{ + "install.sh", + "install-skills.sh", + "install-event.sh", + "install-devapp.sh", +} + +// The ownership-marker contract shared with the Go core +// (internal/upgrade/paths.go): every stamp root DWS creates carries the +// marker, and prune only counts or removes roots with these exact bytes. +const ( + skillBackupMarkerFile = ".dws-skill-backup" + skillBackupMarkerBody = "dws skill backup v1\n" +) + +// extractShellFunction pulls a top-level `name() { ... }` definition out of a +// script so the shipped logic — not a copy — is what the test exercises. +func extractShellFunction(t *testing.T, scriptPath, name string) string { + t.Helper() + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read %s: %v", scriptPath, err) + } + text := string(data) + start := strings.Index(text, "\n"+name+"() {") + if start < 0 { + t.Fatalf("function %s not found in %s", name, scriptPath) + } + start++ + end := strings.Index(text[start:], "\n}\n") + if end < 0 { + t.Fatalf("function %s has no top-level closing brace in %s", name, scriptPath) + } + return text[start : start+end+3] +} + +// mkdirStampWithMarker creates a DWS-owned stamp fixture: the stamp directory +// plus the exact ownership marker bytes prune verifies. Without the marker a +// stamp-shaped directory is foreign data that prune must neither count nor +// remove. +func mkdirStampWithMarker(t *testing.T, backupRoot, stamp string) { + t.Helper() + dir := filepath.Join(backupRoot, stamp) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + mustWriteFile(t, filepath.Join(dir, skillBackupMarkerFile), []byte(skillBackupMarkerBody), 0o644) +} + +// runShellPruneScenario builds a sandbox HOME with the given pre-existing +// (earlier-run) stamps, registers the given stamps as created by the current +// run, optionally also plants stamp-shaped directories without the ownership +// marker, runs the script's own prune_skill_backups, and returns the +// surviving stamp directories. +func runShellPruneScenario(t *testing.T, scriptPath string, foreignStamps, runStamps []string, unmarkedStamps ...string) []string { + t.Helper() + home := t.TempDir() + backupRoot := filepath.Join(home, ".dws", "skill-backups") + for _, stamp := range foreignStamps { + mkdirStampWithMarker(t, backupRoot, stamp) + } + for _, stamp := range unmarkedStamps { + if err := os.MkdirAll(filepath.Join(backupRoot, stamp, "payload"), 0o755); err != nil { + t.Fatal(err) + } + } + + var driver strings.Builder + driver.WriteString("#!/bin/sh\n") + driver.WriteString("say() { :; }\n") + // The keep limit is a script-level variable outside the extracted + // functions; set both spellings the four installers use. + driver.WriteString("DWS_SKILL_BACKUP_KEEP=5\n") + driver.WriteString("SKILL_BACKUP_KEEP=5\n") + driver.WriteString(extractShellFunction(t, scriptPath, "is_skill_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "record_current_run_backup_stamp")) + driver.WriteString("\n") + driver.WriteString(extractShellFunction(t, scriptPath, "prune_skill_backups")) + driver.WriteString("\n") + for _, stamp := range runStamps { + mkdirStampWithMarker(t, backupRoot, stamp) + driver.WriteString("record_current_run_backup_stamp \"$HOME/.dws/skill-backups/" + stamp + "\"\n") + } + driver.WriteString("prune_skill_backups\n") + + driverPath := filepath.Join(t.TempDir(), "prune-driver.sh") + if err := os.WriteFile(driverPath, []byte(driver.String()), 0o755); err != nil { + t.Fatal(err) + } + cmd := exec.Command("sh", driverPath) + cmd.Env = append(os.Environ(), "HOME="+home) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("prune driver failed: %v\n%s", err, out) + } + + entries, err := os.ReadDir(backupRoot) + if err != nil { + t.Fatal(err) + } + var surviving []string + for _, entry := range entries { + if entry.IsDir() { + surviving = append(surviving, entry.Name()) + } + } + return surviving +} + +func assertStampsSurvive(t *testing.T, surviving []string, want ...string) { + t.Helper() + left := make(map[string]bool) + for _, stamp := range surviving { + left[stamp] = true + } + for _, stamp := range want { + if !left[stamp] { + t.Fatalf("stamp %s must survive pruning, surviving=%v", stamp, surviving) + } + delete(left, stamp) + } + for stamp := range left { + t.Fatalf("stamp %s must have been pruned, surviving=%v", stamp, surviving) + } +} + +// TestInstallShellPruneKeepsCurrentRunBackups pins the reversibility contract +// the changelog states for every install surface: pruning may only remove +// batches from earlier runs. The old shell logic deleted the oldest excess +// stamps regardless of origin, so a single migration retiring more than five +// batches silently destroyed its own rollback material. +func TestInstallShellPruneKeepsCurrentRunBackups(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skipf("shell installer tests do not run on %s", runtime.GOOS) + } + for _, name := range shellPruneScriptTargets { + t.Run(name, func(t *testing.T) { + scriptPath := filepath.Join("..", "..", "scripts", name) + if _, err := os.Stat(scriptPath); err != nil { + t.Fatalf("stat %s: %v", scriptPath, err) + } + + t.Run("run that exceeds the keep limit loses nothing", func(t *testing.T) { + var runStamps []string + for i := 1; i <= 8; i++ { + runStamps = append(runStamps, stampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, nil, runStamps) + assertStampsSurvive(t, surviving, runStamps...) + }) + + t.Run("only earlier-run batches are pruned", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 6; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + runStamps := []string{stampName(1), stampName(2)} + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, runStamps) + // Total 8, keep 5: three oldest foreign batches go, the + // current run keeps both of its own. + assertStampsSurvive(t, surviving, + oldStampName(4), oldStampName(5), oldStampName(6), + stampName(1), stampName(2)) + }) + + t.Run("foreign-only history still prunes to the keep limit", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil) + assertStampsSurvive(t, surviving, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7)) + }) + + // A stamp-shaped directory without the ownership marker is foreign + // data: it must be preserved untouched, never counted toward the + // keep limit, and never treated as prunable excess. Oldest on + // purpose — if the marker were not required, this directory would + // be the first pruning candidate. + t.Run("unmarked stamp-shaped directory is preserved and uncounted", func(t *testing.T) { + var foreignStamps []string + for i := 1; i <= 7; i++ { + foreignStamps = append(foreignStamps, oldStampName(i)) + } + unmarked := "20200101-000000" + surviving := runShellPruneScenario(t, scriptPath, foreignStamps, nil, unmarked) + assertStampsSurvive(t, surviving, + append([]string{unmarked}, + oldStampName(3), oldStampName(4), oldStampName(5), + oldStampName(6), oldStampName(7))...) + }) + }) + } +} + +func stampName(i int) string { + return "20260817-0000" + twoDigits(i) +} + +func oldStampName(i int) string { + return "20200101-0000" + twoDigits(i) +} + +func twoDigits(i int) string { + if i < 10 { + return "0" + string(rune('0'+i)) + } + return string(rune('0'+i/10)) + string(rune('0'+i%10)) +} + +// TestInstallShellBackupNeverAdoptsUnprovenStampRoots pins the ownership +// contract at creation time: a stamp-shaped directory whose marker does not +// verify must never receive the DWS marker or a payload — the backup moves +// to a fresh suffixed root instead, and the foreign data survives untouched. +func TestInstallShellBackupNeverAdoptsUnprovenStampRoots(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("POSIX shell semantics are unavailable") + } + + for _, scriptName := range []string{"install.sh", "install-skills.sh", "install-event.sh", "install-devapp.sh"} { + scriptName := scriptName + t.Run(scriptName, func(t *testing.T) { + t.Parallel() + scriptPath, err := filepath.Abs(filepath.Join("..", "..", "scripts", scriptName)) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatal(err) + } + cut := strings.LastIndex(string(data), "\nmain\n") + if cut < 0 { + t.Fatalf("%s final main invocation not found", scriptName) + } + library := filepath.Join(t.TempDir(), scriptName+"-lib.sh") + mustWriteFile(t, library, data[:cut], 0o755) + + home := t.TempDir() + // install.sh and install-skills.sh name the payload by basename; + // the standalone event/devapp publishers mangle the full path. + payloadName := "victim" + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + // Mirror the scripts' own mangling: full path, slashes and + // backslashes to dashes, leading dash dropped. + payloadName = strings.NewReplacer("/", "-", "\\", "-").Replace(filepath.Join(home, "victim")) + payloadName = strings.TrimPrefix(payloadName, "-") + } + harness := `. "$DWS_TEST_LIBRARY" +date() { printf '%s\n' 20260101-120000; } +ROOT="$HOME/.dws/skill-backups" +mkdir -p "$ROOT/20260101-120000" || exit 9 +printf 'must-survive\n' > "$ROOT/20260101-120000/user-data.txt" || exit 9 +mkdir -p "$HOME/victim" || exit 9 +printf 'payload\n' > "$HOME/victim/SKILL.md" || exit 9 +if ! backup_fn "$HOME/victim" >/dev/null 2>&1; then + if [ -e "$HOME/victim/SKILL.md" ]; then + exit 0 # a refused backup that keeps the source is contract-compliant + fi + exit 9 +fi +[ -f "$ROOT/20260101-120000-1/` + "`printf '%s' \"" + payloadName + "\"`" + `/SKILL.md" ] || exit 3 +[ ! -e "$HOME/victim" ] || exit 4 +[ -f "$ROOT/20260101-120000/user-data.txt" ] || exit 5 +[ "$(cat "$ROOT/20260101-120000/user-data.txt")" = "must-survive" ] || exit 6 +[ ! -e "$ROOT/20260101-120000/.dws-skill-backup" ] || exit 7 +[ "$(cat "$ROOT/20260101-120000-1/.dws-skill-backup" 2>/dev/null)" = "dws skill backup v1" ] || exit 8 +exit 0 +` + harnessPath := filepath.Join(t.TempDir(), "backup-adoption.sh") + mustWriteFile(t, harnessPath, []byte(harness), 0o755) + cmd := exec.Command("sh", "-c", "backup_fn() { "+backupFnName(scriptName)+" \"$1\"; }\n. \"$0\"", harnessPath) + cmd.Env = append(os.Environ(), "HOME="+home, "DWS_TEST_LIBRARY="+library) + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("%s must not adopt an unproven stamp root: %v\n%s", scriptName, err, string(output)) + } + if backup := findSkillBackupContent(home, "payload\n"); !strings.Contains(backup, "20260101-120000-1") { + t.Fatalf("%s payload must land in the suffixed root, got %s", scriptName, backup) + } + }) + } +} + +func backupFnName(scriptName string) string { + if scriptName == "install-event.sh" || scriptName == "install-devapp.sh" { + return "backup_skill_dir" + } + return "backup_and_remove_skill_dir" +} diff --git a/test/skill_tests.md b/test/skill_tests.md index 8c1b6c4e..2325e43d 100644 --- a/test/skill_tests.md +++ b/test/skill_tests.md @@ -1190,6 +1190,10 @@ Agent 安装 dws skill 后,仅依据 skill 提供的参考文档,将自然 - Prompt: 有和我相关的审批实例发起时实时通知我 - Expected: `dws event consume user_oa_approval_instance_started --flatten -f ndjson` +**event_event_consume_oa_instance_cc_001** +- Prompt: 有审批实例抄送给我时实时通知我 +- Expected: `dws event consume user_oa_approval_instance_cc --flatten -f ndjson` + **event_event_consume_oa_instance_terminated_001** - Prompt: 和我相关的审批实例终止时实时通知我 - Expected: `dws event consume user_oa_approval_instance_terminated --flatten -f ndjson` diff --git a/test/unit/oa_attachment_delivery_policy_test.go b/test/unit/oa_attachment_delivery_policy_test.go index cd5f7cd7..ba74452e 100644 --- a/test/unit/oa_attachment_delivery_policy_test.go +++ b/test/unit/oa_attachment_delivery_policy_test.go @@ -78,6 +78,7 @@ func TestCrossPlatformCoverageOAAttachmentDeliveryPolicy(t *testing.T) { "dws oa approval attachment download-url", "dws oa approval attachment authorize-download", "dws oa approval attachment authorize-preview", + "dws oa approval attachment upload", "临时下载链接", "最多 10", "最多 20", diff --git a/test/unit/skill_docs_policy_test.go b/test/unit/skill_docs_policy_test.go index 45877f26..d5c36cf1 100644 --- a/test/unit/skill_docs_policy_test.go +++ b/test/unit/skill_docs_policy_test.go @@ -280,7 +280,7 @@ func TestEventSkillFrontmatterAdvertisesGroupMemberLifecycle(t *testing.T) { "群成员加入", "群成员退出", "审批任务创建/完成/转交", - "审批实例发起/终止/完成", + "审批实例发起/抄送/终止/完成", } { if !strings.Contains(frontmatter, required) { t.Errorf("%s frontmatter missing event discovery trigger %q", path, required) @@ -306,7 +306,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) { "", "", "16 个 EventKey", - "22 个公开个人 EventKey", + "23 个公开个人 EventKey", } { if !strings.Contains(string(skillContent), required) { t.Errorf("%s missing standalone event contract %q", skillPath, required) @@ -360,6 +360,7 @@ func TestStandaloneEventSkillOwnsAllPersonalEventContracts(t *testing.T) { "user_oa_approval_task_finished", "user_oa_approval_task_redirected", "user_oa_approval_instance_started", + "user_oa_approval_instance_cc", "user_oa_approval_instance_terminated", "user_oa_approval_instance_finished", }