Compare commits

...
Author SHA1 Message Date
Dennis 11a9ad5d49 fix(shortcut): align OA execution availability 2026-08-20 23:03:35 +08:00
Dennis 8cb0f64477 fix(shortcut): reject backward OA cursors 2026-08-20 22:15:27 +08:00
Dennis 2d38beb7be fix(shortcuts): separate compatibility visibility from availability 2026-08-20 21:51:21 +08:00
Dennis bda408d966 test(ding): cover compatibility reminder mappings 2026-08-20 21:10:45 +08:00
Dennis 33631502c9 fix(shortcuts): align unavailable writes and query validation 2026-08-20 21:02:52 +08:00
Dennis 5c9f738012 fix(shortcuts): preserve published schema bindings 2026-08-20 20:20:41 +08:00
Dennis 56c5fb35b8 chore(policy): retire completed flag migrations 2026-08-20 20:20:27 +08:00
Dennis b19c52f61b fix(oa): make approval keyword normalization explicit 2026-08-20 20:20:25 +08:00
Dennis c0641dbf64 fix(shortcut): preserve OA and DING CLI compatibility 2026-08-20 20:20:23 +08:00
Dennis 3b5cb3b0cb test(shortcut): close OA DING Report coverage gaps 2026-08-20 20:20:21 +08:00
Dennis fd2ed2174f chore(release): add OA DING Report fragment 2026-08-20 20:20:19 +08:00
Dennis 5bbaa304e3 docs(shortcut): refresh OA DING Report live evidence 2026-08-20 20:20:17 +08:00
Dennis db938778af chore(shortcut): sync OA DING Report with current main 2026-08-20 20:20:15 +08:00
Dennis 1155b9b5c0 test(shortcut): align OA reviewed input fixtures 2026-08-20 20:20:12 +08:00
Dennis 8fa93ef030 fix(shortcut): retire OA discovery aliases after downgrade 2026-08-20 20:20:10 +08:00
Dennis f4ad1a15f5 docs(shortcut): record Report double-layer release proof 2026-08-20 20:20:08 +08:00
Dennis d2cbd928e2 docs(shortcut): record DING double-layer release proof 2026-08-20 20:20:06 +08:00
Dennis 2346dfba4e fix(shortcut): require OA zero-page pagination evidence 2026-08-20 20:20:03 +08:00
Dennis f6ad2fa01a fix(shortcut): publish Report range constraints 2026-08-20 20:19:44 +08:00
Dennis 7bc56f3dea feat(shortcut): unlock Report outbox workflows 2026-08-20 20:19:41 +08:00
Dennis 03001eb4e0 fix(shortcut): harden DING write routing evidence 2026-08-20 20:19:39 +08:00
Dennis 91974ce981 docs(shortcut): close OA residual audit gaps 2026-08-20 20:19:37 +08:00
Dennis c6417e3527 feat(shortcut): harden Report reads and availability 2026-08-20 20:19:35 +08:00
Dennis 161687ae4c fix(shortcut): deliver OA validation evidence 2026-08-20 20:19:32 +08:00
Dennis 4da5a07d1d feat(shortcut): harden DING reads and availability 2026-08-20 20:19:30 +08:00
Dennis 2cb83d388b fix(shortcut): publish OA validation constraints 2026-08-20 20:19:25 +08:00
Dennis ba9c0f624e feat(shortcut): harden OA workflows and availability 2026-08-20 20:19:19 +08:00
Dennis ff65f80c98 refactor(oa): add strict shortcut response helpers 2026-08-20 20:19:09 +08:00
github-actions[bot] 42240f5e9e Merge pull request #1079 from DingTalk-Real-AI/codex/fix-stable-migration-receipts
fix(ci): keep completed migration receipts inert
2026-08-20 20:18:04 +08:00
chichuan 11cbc30a10 fix(ci): keep completed migration receipts inert 2026-08-20 19:16:07 +08:00
github-actions[bot] 62d72ad84c chore: update formula for v1.0.59 [skip ci] 2026-08-20 08:45:55 +00:00
赤川 c0838e7e41 Merge pull request #1072 from DingTalk-Real-AI/codex/changelog-v1.0.59-stable
chore: prepare v1.0.59 changelog
2026-08-20 16:06:54 +08:00
chichuan 9c6ab99bf1 chore: prepare v1.0.59 changelog 2026-08-20 16:01:09 +08:00
github-actions[bot] 87ab311764 chore: update beta formula for v1.0.59-beta.5 [skip ci] 2026-08-20 07:55:40 +00:00
赤川 15c075e6a6 Merge pull request #1068 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.5
chore: prepare v1.0.59-beta.5 changelog
2026-08-20 14:56:06 +08:00
chichuan f6d1e685e0 chore: prepare v1.0.59-beta.5 changelog 2026-08-20 14:52:30 +08:00
github-actions[bot] 81108e150b Merge pull request #1046 from xlb1130/feat/85614588-chat-personal-emotion
feat(chat): add personal emotion commands
2026-08-20 06:27:50 +00:00
xlb1130 dad9aefefa Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 14:08:05 +08:00
github-actions[bot] 71d49cb12b Merge pull request #1033 from pengzhihan47-star/codex/dingtalk-doc-skill-opt-v1
docs(skill): optimize dingtalk-doc workflows
2026-08-20 14:04:45 +08:00
柏智 f7e2efaaa2 ci: retrigger checks 2026-08-20 13:50:18 +08:00
pengzhihan47-star 557208e16b Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:31:31 +08:00
xlb1130 53401dbb0c Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:25:41 +08:00
github-actions[bot] 6c52ac37dd Merge pull request #1066 from DingTalk-Real-AI/codex/minutes-todo-wiki-param-aliases
feat(cli): expand Minutes TODO Wiki parameter aliases
2026-08-20 13:21:38 +08:00
xlb1130 95a17a3ffc Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 13:21:07 +08:00
pengzhihan47-star 3318741508 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 13:01:04 +08:00
克谨 3d7ab2690c feat(cli): expand Minutes TODO Wiki parameter aliases 2026-08-20 12:45:19 +08:00
github-actions[bot] 17eefcd24b Merge pull request #1064 from DingTalk-Real-AI/codex/fix-1060-schema-lineage
fix(policy): preserve historical Schema migration lineage
2026-08-20 04:29:42 +00:00
xlb1130 6f62ce7997 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 12:17:24 +08:00
赤川 2228a32d1a Merge branch 'main' into codex/fix-1060-schema-lineage 2026-08-20 12:11:55 +08:00
github-actions[bot] a6f79e951b Merge pull request #1050 from DingTalk-Real-AI/codex/fix-cli-eval-functional
fix: harden shortcut functional workflows
2026-08-20 04:08:39 +00:00
长真 096dfd48f0 docs(chat): keep emotion skill route within budget 2026-08-20 11:57:55 +08:00
chichuan 3922970bfc fix(policy): preserve schema migration lineage 2026-08-20 11:52:00 +08:00
Dennis4477 9b0441ca56 Merge branch 'main' into codex/fix-cli-eval-functional 2026-08-20 11:47:08 +08:00
xlb1130 2ab0edd5c6 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:45:41 +08:00
pengzhihan47-star 4b3272bcd4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:42:41 +08:00
github-actions[bot] b6eaf3c5af chore: update beta formula for v1.0.59-beta.4 [skip ci] 2026-08-20 03:42:00 +00:00
长真 80d5d24637 Revert "docs(chat): trim chat skill context budget"
This reverts commit c5951a10ff.
2026-08-20 11:39:50 +08:00
柏智 e40397e239 docs(skill): restore bounded doc guidance 2026-08-20 11:34:14 +08:00
xlb1130 15bc7fdc3f Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-20 11:27:40 +08:00
柏智 da049be58d docs(skill): require terminal evidence for doc writes 2026-08-20 11:21:58 +08:00
柏智 6ec64e8a03 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:24 +08:00
柏智 bca56cbba6 Merge remote-tracking branch 'origin/codex/dingtalk-doc-skill-opt-v1' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 11:09:19 +08:00
赤川 aa4ae9a903 Merge pull request #1063 from DingTalk-Real-AI/codex/fix-996-multi-profile-skill-path
fix(ci): align multi-profile skill paths with canonical setup
2026-08-20 10:53:27 +08:00
chichuan 7a019c6fa3 fix(ci): align multi-profile skill paths 2026-08-20 10:46:17 +08:00
john bb48aa0cc8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:44 +08:00
柏智 6ffb4bcb93 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:37:30 +08:00
赤川 e2e4d6fc22 Merge pull request #1062 from DingTalk-Real-AI/codex/changelog-v1.0.59-beta.4
chore: prepare v1.0.59-beta.4 changelog
2026-08-20 10:30:46 +08:00
chichuan 2c0d6e4118 chore: prepare v1.0.59-beta.4 changelog 2026-08-20 10:25:13 +08:00
pengzhihan47-star 08595594d7 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 10:15:26 +08:00
github-actions[bot] 379f625ca9 Merge pull request #1045 from DingTalk-Real-AI/codex/attendance-mail-shortcuts
feat(shortcut): harden Attendance and Mail workflows
2026-08-20 02:07:16 +00:00
柏智 540bbac35b Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 09:57:23 +08:00
赤川 9d27313f5e Merge branch 'main' into codex/attendance-mail-shortcuts 2026-08-20 09:47:51 +08:00
github-actions[bot] cc86d1e958 Merge pull request #1043 from guimingyue/oa_approval_list_by_admin
feat(oa): add approval list-by-admin with string time contract
2026-08-20 01:47:23 +00:00
mygui 5619cb150e Merge branch 'main' into oa_approval_list_by_admin 2026-08-20 09:28:43 +08:00
柏智 c4d5595ca9 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-20 02:09:49 +08:00
github-actions[bot] 5aaf2efb59 Merge pull request #1060 from DingTalk-Real-AI/codex/govern-command-path-migrations
ci: govern Help and Schema command migrations
2026-08-20 02:04:43 +08:00
chichuan d583247935 test(ci): cover command governance branches 2026-08-20 01:50:13 +08:00
chichuan dfc3b028d7 fix(ci): prove extracted command constants end to end 2026-08-20 00:53:23 +08:00
chichuan 95da8214a1 test(ci): reject command parameter target collisions 2026-08-19 23:24:29 +08:00
chichuan d8a3d5d6fd fix(ci): close command migration governance gaps 2026-08-19 22:57:11 +08:00
柏智 86d1eb8030 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:47:50 +08:00
chichuan 5ed7c3adce Merge remote-tracking branch 'origin/main' into codex/govern-command-path-migrations 2026-08-19 22:37:21 +08:00
github-actions[bot] d1f1ab724b Merge pull request #1058 from Anonymity-0/feat/chat-group-role-single-flag
feat(chat): expose single group role flag
2026-08-19 22:16:58 +08:00
柏智 ab529e5ee5 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 22:05:02 +08:00
xlb1130 15cb1f4311 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 22:01:44 +08:00
前津 67505c6c83 Merge remote-tracking branch 'upstream/main' into feat/chat-group-role-single-flag 2026-08-19 21:55:58 +08:00
长真 97ca00868f test(chat): cover personal emotion user resolution 2026-08-19 21:51:49 +08:00
github-actions[bot] 61c39efb85 Merge pull request #996 from typefield/fix/canonical-agent-skills
fix(skills): adopt canonical global installation
2026-08-19 21:48:33 +08:00
前津 5b412ac196 test(chat): cover role flag resolver branches 2026-08-19 21:48:28 +08:00
玉澜 c0e579fe6b fix(skills): prove backup ownership before adopting or pruning stamp roots
A stamp-shaped directory name is not ownership proof: pruneSkillBackups
counted and RemoveAll'd any 20260819-120000-shaped entry under
~/.dws/skill-backups, so a user or tool that created such a directory
lost its contents once DWS held five backups, and the Go backup path
(MkdirAll) adopted a same-named foreign root outright. The PowerShell
installers already implemented the correct contract; every other
surface now matches it.

Go stamps a freshly created root with the exact marker bytes the
install scripts write (.dws-skill-backup = "dws skill backup v1")
before any payload moves in, claims the root with mkdir so an existing
unproven root bumps to a collision suffix instead of being adopted,
and prunes only roots whose marker verifies — unmarked or wrongly
worded stamp-shaped directories are foreign data, preserved and never
counted against the keep limit. The shell installers (install.sh,
install-skills.sh, install-event.sh, install-devapp.sh) and the npm
installer apply the same rule in their backup collision loops, with
roots recorded as created by the running process exempt from marker
re-verification so a mid-run marker permission failure still reuses
this run's own root and keeps the sibling payload intact.

Regression tests cover every surface: pruning an unmarked/wrongly
marked stamp-shaped directory alongside marked ones, refusing to adopt
a foreign root (payload moves to a suffixed root, foreign data and its
nonexistent marker untouched), same-stamp reuse of a proven root, and
marker-write failure cleaning the empty fresh root.
2026-08-19 21:29:47 +08:00
前津 c2ff4ab242 test(chat): cover missing group role flag 2026-08-19 21:26:52 +08:00
前津 3fa85d19c9 docs: add group role flag release fragment 2026-08-19 21:22:55 +08:00
xlb1130 df8885c350 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 21:06:35 +08:00
前津 33b76400bf chore(policy): consume group role flag migration 2026-08-19 21:01:34 +08:00
Anonymity-0 2b417e2f2a Merge branch 'main' into feat/chat-group-role-single-flag 2026-08-19 20:51:06 +08:00
chichuan c0e1ec576a ci: govern command path migrations 2026-08-19 20:48:43 +08:00
玉澜 2b3f482fdc Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills-p1 2026-08-19 19:58:56 +08:00
赤川 f79c066806 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 19:51:24 +08:00
玉澜 95645e4f2c fix(skills): no-clobber child moves in POSIX standalone publishers
copy_tree published staged children with mv, which replaces a
concurrently created same-name directory (POSIX rename succeeds over an
empty target) and whose rollback moved every dest child back — including
a concurrent writer's different-named entries — before deleting the
staging tree. Children now publish through kernel-level no-clobber
primitives (mkdir claim + recursion for directories with the recorded
mode restored, ln for regular files, ln -s for symlinks), a manifest
records exactly what this transaction published, the rollback retracts
only those entries in reverse order, and each level re-counts the
destination so a foreign different-named entry aborts the publish with
the destination retained. Read-only staged directories (0555 skill
trees) are made owner-writable for the move; the backup restore uses the
same discipline so a concurrent writer is refused without partially
draining the backup.

Regression tests cover both scripts: a concurrently created same-name
empty child directory and a different-named foreign entry mid-publish
are retained with the original backup kept; both fail against the
previous mv-based implementation.
2026-08-19 19:46:47 +08:00
柏智 4e27a3a84a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 19:45:13 +08:00
前津 575303a5b0 docs(skill): remove stale group role flag guidance 2026-08-19 19:40:41 +08:00
github-actions[bot] d9b728f8e5 Merge pull request #1059 from Anonymity-0/feat/chat-group-role-flag-migration-approval
chore(policy): approve group role flag migration
2026-08-19 19:33:13 +08:00
xlb1130 8685464c53 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 19:28:52 +08:00
前津 6240584ae2 chore(policy): approve group role flag migration 2026-08-19 19:13:40 +08:00
玉澜 cb46823280 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills-p1 2026-08-19 19:08:24 +08:00
玉澜 fdcaa61587 test(skills): cover child-move edges for the 100% changed-code gates
The platform coverage gates execute only TestCrossPlatformCoverage-named
tests, so the child-move error and dispatch branches that the full local
suite covered incidentally were reported as uncovered changed code on
Windows (96.78% vs the 100% target). Adds a seam-driven edge suite for
the child-move fallback — source/claim/child stat and read failures,
per-child link and symlink collisions and publish failures, rollback
rename failure, foreign-entry abort, mode-restore failure, source shell
removal failure, nested-directory and simulated-symlink children, and
post-rename content drift — plus the retained-destination notice for a
dependent uncertain target in skill setup. The POSIX file identity impl
now consults the lstat seam so its degradation branches are coverable
the same way. Verified against the gate's own changed-line computation:
zero uncovered changed statements in internal/upgrade.
2026-08-19 18:54:04 +08:00
mygui a0495c169b Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 18:50:35 +08:00
Anonymity-0 3e481d296c Merge branch 'main' into feat/chat-group-role-single-flag 2026-08-19 18:49:59 +08:00
前津 22f87296cd fix(chat): close role flag resolver 2026-08-19 18:46:38 +08:00
长真 26b5939f9f chore(ci): retrigger pr checks 2026-08-19 18:34:38 +08:00
github-actions[bot] c198d8577d Merge pull request #976 from H3java/feat/recruit-job
feat: 新增招聘职位管理 to#85340676
2026-08-19 10:30:55 +00:00
玉澜 33828b7858 Merge remote-tracking branch 'fork/fix/canonical-agent-skills' into fix/canonical-agent-skills-p1 2026-08-19 18:28:04 +08:00
玉澜 0c80aa79e5 test(skills): make replacement-identity tests deterministic on Windows
The publish-confirmation and tunneled-replacement tests physically
removed and reseeded the destination to simulate a concurrent swap. On
NTFS the recreation can immediately reuse the freed MFT record, making
the file ID (volume serial + file index) compare equal and the proof
pass against a replaced object — the Windows coverage gate observed the
confirmation falling through to the fingerprint branch instead of the
identity branch. Both tests now force the replacement through the two
primitives the platform proof consults (os.SameFile on Unix, the file-ID
seam on Windows), matching the technique the tunneled-rollback case
already used for Unix inode recycling.
2026-08-19 18:27:39 +08:00
john da62d11b35 Merge branch 'main' into fix/canonical-agent-skills 2026-08-19 18:13:21 +08:00
赤川 a5d7fd05f1 Merge branch 'main' into feat/recruit-job 2026-08-19 18:12:36 +08:00
玉澜 cf13026f48 fix(skills): drop stale Statx identity test from merged remote line
skill_publication_identity_linux_test.go pinned the remote line's
Statx/birth-time identity design (skillPathStatx seam); the merged head
proves ownership with dev:ino plus the fingerprint backstop instead, so
the test no longer compiles on Linux. Caught by CI's Linux lint job,
which builds what macOS-local vet skips behind the linux build tag.
2026-08-19 18:10:23 +08:00
柏智 95bcace6bd Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 18:10:22 +08:00
mingyue.gmy 20c901d7ae fix(oa): require processCode in list-by-admin --request payloads
- Reject --request payloads with a missing, empty, or non-string
  processCode; the backend answers a bad processCode with success:true
  and an empty list, so validate client-side like startTime
- Add regression cases to keep changed-code coverage at 100%
2026-08-19 18:06:27 +08:00
玉澜 7a858b9732 Merge remote branch (main evolution + npm/Shell no-clobber) into p1
Reconciles the two parallel evolutions of PR #996 with this session's
publication design as authoritative:

- internal/upgrade, internal/app: ours — mkdir-claim identity witness
  (dev:ino on POSIX, volume file ID on Windows), three-state ownership,
  ErrSkillPathPublicationUncertain, copy-fallback short-circuits. Drops
  the remote line's xattr publication-mark design and its six follow-up
  fixes (retract contracts, Statx token); skill_publication_mark_*.go
  removed accordingly.
- scripts/, build/npm/, test/scripts/, docs/rfc: theirs — same replayed
  install hardening plus main's evolution and the npm no-clobber child
  moves; no xattr dependency, consistent with the claim model.
- .changes: their npm/Shell/PowerShell narrative with the Go-design
  sentences rewritten for the uncertain-publication contract.

Verified: go build, go vet (tests compiled), gofmt, and package tests
for internal/upgrade, internal/app, test/scripts all green on this tree.
2026-08-19 18:06:06 +08:00
github-actions[bot] 00c337c438 Merge pull request #1052 from DingTalk-Real-AI/codex/fix-chat-user-mentions
fix(chat): preserve mentions and route direct media uploads
2026-08-19 18:05:50 +08:00
玉澜 27aca3ccc3 fix(skills): close no-replace fallback TOCTOU and surface uncertain publications
The mkdir->rename->remove->rename directory fallback had a TOCTOU window
between the second remove and the second rename: a concurrent writer
creating an entry at the destination was silently clobbered. The fallback
now claims the destination once with mkdir and never unlinks it: the
fast-path rename publishes over the claim (Linux), and platforms that
refuse directory renames (macOS, Windows) move the staged children into
the claim through atomic no-clobber primitives (mkdir/os.Link/os.Symlink),
consuming the emptied source shell on success.

renameSkillPathNoReplace now returns the mkdir-claim identity captured by
the child-move path. PublishSkillPathNoReplace uses it as a three-state
ownership witness: the atomic/fast paths keep the staged-inode proof, the
child-move path proves dest is still the mkdir claim, and a mismatch
reports the new ErrSkillPathPublicationUncertain sentinel with the
destination retained. The witness is real on POSIX now: darwin and linux
report the dev:ino file identity instead of the empty no-op.

Upstream consumers honor the sentinel: the mono/multi upgrade copy
fallbacks no longer retry over an uncertain destination (the retry would
displace the concurrent writer's object), and skill setup reports the
retained destination instead of claiming a rollback.

Rewrites the fallback tests that pinned the removed remove-and-retry flow
and adds regression coverage: concurrent claim entries abort with the
destination retained, wholesale replacement after child-move reports the
uncertain sentinel, staged-set transactions pass the sentinel through,
and both copy fallbacks short-circuit (ablation-verified).
2026-08-19 17:42:20 +08:00
xlb1130 84036678dd Merge branch 'main' into fix/85564002-chat-group-role-single-flag 2026-08-19 17:26:10 +08:00
长真 d5031a89f0 fix(chat): reject multiple public group role ids 2026-08-19 17:13:48 +08:00
mingyue.gmy e51ecc04f1 ci: trigger workflow rerun 2026-08-19 17:09:14 +08:00
长真 ce57cdf260 chore(ci): retrigger pr checks 2026-08-19 16:26:32 +08:00
Dennis 17741851f5 test: satisfy native shortcut coverage gate 2026-08-19 16:20:04 +08:00
恋川 ed1ebe5d03 chore: retrigger CI 2026-08-19 16:11:24 +08:00
Dennis d10446bea7 ci: reuse preinstalled archive tooling 2026-08-19 15:50:03 +08:00
xlb1130 3ec138ba99 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 15:14:09 +08:00
Dennis 0ed05a2c5d fix: address shortcut review edge cases 2026-08-19 14:46:27 +08:00
Dennis ae1edefee6 test: cover shortcut hardening branches 2026-08-19 14:46:23 +08:00
Dennis 6dbc7ed82b fix: harden shortcut functional workflows 2026-08-19 14:46:19 +08:00
恋川 0d22a4a1bd Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 14:43:50 +08:00
mygui 586ad0a5df Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 14:41:40 +08:00
克谨 83f3b4f385 Merge remote-tracking branch 'origin/main' into codex/fix-chat-user-mentions 2026-08-19 14:39:42 +08:00
Dennis 6d88c9968e docs(attendance): clarify schedule availability 2026-08-19 14:37:38 +08:00
Dennis 36c61fd8ac fix(attendance): withhold unverifiable schedule query 2026-08-19 14:37:35 +08:00
Dennis 272b6b8a70 test(shortcut): lock public catalog count 2026-08-19 14:37:33 +08:00
Dennis c3328411c9 fix(shortcut): close mail review and schema compatibility 2026-08-19 14:37:31 +08:00
Dennis 83cfd10416 docs(shortcut): record final live review evidence 2026-08-19 14:37:29 +08:00
Dennis 9d43a12e08 fix(shortcut): address Attendance and Mail review findings 2026-08-19 14:37:27 +08:00
Dennis 7900e27946 fix(shortcut): preserve CLI compatibility for unavailable leaves 2026-08-19 14:37:25 +08:00
Dennis 42f54832b0 docs(shortcut): refresh rebased evidence references 2026-08-19 14:37:23 +08:00
恋川 e217901a6b fix(recruit): validate education list filter 2026-08-19 14:37:22 +08:00
Dennis 5f6ca90821 docs(shortcut): sync live evidence and generated lists 2026-08-19 14:37:21 +08:00
Dennis cad437aabd fix(attendance): filter validated record overfetch 2026-08-19 14:37:19 +08:00
Dennis 47d71375f6 fix(attendance): align live identity and availability 2026-08-19 14:37:17 +08:00
Dennis 69540c3372 fix(mail): preserve shortcut query schema property 2026-08-19 14:37:15 +08:00
Dennis 3a2b738c06 fix(shortcut): close attendance and mail release gates 2026-08-19 14:37:13 +08:00
Dennis 725e60f07c feat(mail): harden and align shortcut workflows 2026-08-19 14:37:11 +08:00
Dennis 8b4453adf9 feat(attendance): harden shortcut contracts and live evidence 2026-08-19 14:37:09 +08:00
柏智 f419c0f96d Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 14:37:01 +08:00
克谨 63854705fd fix(chat): route direct media upload targets 2026-08-19 14:22:02 +08:00
恋川 109a2891de Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 13:48:45 +08:00
玉澜 4e106cd5ad Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-19 13:44:46 +08:00
xlb1130 17101a8901 Merge branch 'main' into feat/85614588-chat-personal-emotion 2026-08-19 13:41:20 +08:00
玉澜 9858844158 fix(skills): no-clobber child moves in npm publish 2026-08-19 13:37:11 +08:00
克谨 00ca448aa2 docs(release): add chat mention fix fragment 2026-08-19 13:34:45 +08:00
克谨 afe01d4b70 Merge remote-tracking branch 'origin/main' into codex/fix-chat-user-mentions 2026-08-19 13:30:47 +08:00
克谨 4c6db326f5 fix(chat): preserve and validate user mention tokens 2026-08-19 13:30:40 +08:00
柏智 66aa00fb50 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 12:23:42 +08:00
john 843edd700d Merge branch 'main' into fix/canonical-agent-skills 2026-08-19 11:36:13 +08:00
恋川 58ff0248b6 fix(recruit): handle minimal terminal pages 2026-08-19 11:05:29 +08:00
长真 9d6e151a6f Merge remote-tracking branch 'upstream/main' into feat/85614588-chat-personal-emotion 2026-08-19 10:54:30 +08:00
柏智 0df41d3eff Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:40:45 +08:00
mygui ab0d1d2ad6 Merge branch 'main' into oa_approval_list_by_admin 2026-08-19 10:17:51 +08:00
恋川 6c895c23ed fix(recruit): validate pagination cursor responses 2026-08-19 10:17:18 +08:00
柏智 2a1ed8cc7a Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-19 10:09:48 +08:00
mingyue.gmy 358e1ab065 fix(oa): require startTime in --request and validate endTime independently
- Reject --request payloads missing startTime (documented required) so
  endTime can no longer bypass validation when startTime is absent
- Align --request time ordering with simple mode: endTime must be
  strictly after startTime
- Cover all five previously uncovered branches (pageSize absent,
  startTime absent, malformed endTime, valid time pair, empty --start
  flag) to reach 100% changed-code coverage
2026-08-19 10:03:29 +08:00
恋川 510b120630 fix(recruit): require job creator identity 2026-08-19 09:31:26 +08:00
恋川 f253f865c7 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-19 09:29:22 +08:00
玉澜 8e34134dbc Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-19 08:03:42 +08:00
玉澜 65885dd262 fix(skills): abort degraded publish on foreign claim entries 2026-08-19 05:54:20 +08:00
长真 c5951a10ff docs(chat): trim chat skill context budget 2026-08-19 00:55:27 +08:00
玉澜 b354b371c9 fix(skills): prune backups without following reparse points 2026-08-19 00:15:31 +08:00
玉澜 15d289d3f3 fix(skills): keep sibling backups when marker write fails 2026-08-19 00:15:27 +08:00
长真 3dbd29ab50 feat(chat): add personal emotion commands 2026-08-18 23:59:15 +08:00
柏智 1b50c7a5b4 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 23:25:57 +08:00
玉澜 57b5845eb3 fix(skills): verify content fingerprint in shell rollback 2026-08-18 22:30:51 +08:00
mingyue.gmy fab84af434 docs(changelog): add release fragment for oa approval list-by-admin 2026-08-18 21:32:00 +08:00
mingyue.gmy 2dd724f1e1 feat(oa): add approval list-by-admin with string time contract
- Add dws oa approval list-by-admin leaf with simple flags and
  advanced --request modes backed by get_process_instances_by_admin
- Send startTime/endTime as yyyy-MM-dd HH:mm:ss strings per the
  2026-08 MCP contract update; ISO-8601 flag inputs auto-convert
- Enforce pageSize cap (20) and string time format/order client-side;
  PreRunE reports flag-group violations in Chinese before Cobra's
  built-in English validation
- Extend coverage tests and document the command in mono/multi OA
  skill references
2026-08-18 21:10:23 +08:00
柏智 cbd70d1b88 Merge remote-tracking branch 'upstream/main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 21:03:52 +08:00
玉澜 3ac9b83565 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 21:00:14 +08:00
柏智 0ae8949d40 fix(doc): align skill contracts with runtime 2026-08-18 20:53:02 +08:00
玉澜 234253e75f test(skills): cover linux statx identity without btime 2026-08-18 20:42:04 +08:00
玉澜 6a4803d85a fix(skills): verify backup ownership marker before pruning 2026-08-18 20:42:01 +08:00
pengzhihan47-star 0975d970d1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 20:36:25 +08:00
柏智 7808673431 fix(doc): align media receipt contract 2026-08-18 20:31:07 +08:00
玉澜 d11e69fbdb test(skills): cover copy fallback dest scan branches 2026-08-18 19:41:33 +08:00
玉澜 a3566f39c4 test(skills): cover unix publication identity fallbacks 2026-08-18 19:33:56 +08:00
玉澜 a192e988c4 fix(skills): refuse unplanned dests in copy fallback 2026-08-18 19:33:51 +08:00
pengzhihan47-star 50ed921ca1 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 19:28:45 +08:00
pengzhihan47-star b34c29ec35 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 18:41:09 +08:00
玉澜 91d2e29925 test(skills): cover publication mark ownership branches
Windows coverage gate only runs TestCrossPlatformCoverage*, and the
xattr mark helpers are Unix-only. Inject seams so marked dest is
retracted on owned drift, left in place when the mark is gone, and
the helper error paths are exercised on every platform.
2026-08-18 17:50:39 +08:00
长真 26638cbd98 fix(chat): complete group role set-user flag compatibility 2026-08-18 17:44:51 +08:00
玉澜 2f05649277 fix(skills): keep concurrent dest across inode reuse
Linux overlayfs recycles device+inode, so SameFile and a lone inode
token treated a replacement as owned and retracted it. Stamp staged
inodes with an xattr mark, prove Linux/Darwin identity with birth
time, and make shell copied-set rollback check dest first with inode
plus child names.
2026-08-18 17:34:49 +08:00
长真 6c8e7e082b fix(chat): expose single group role set flag 2026-08-18 17:13:50 +08:00
玉澜 63a6de7b49 fix(skills): prove npm rollback ownership before quarantine
Match the Go dest-first identity check so a concurrent replacement is
never moved into .rollback-*; only a post-quarantine mismatch is
restored with no-replace. Cover both races in the npm smoke suite.
2026-08-18 16:40:21 +08:00
玉澜 46b641f227 fix(skills): retract leftover dest and qualify Windows junctions
Record dest on occupy and retract it when confirmation, verify, or
staging cleanup fails. Restore unmatched quarantine with a no-replace
publish. Event/devapp copy uses mkdir-claim; shell rollback claims dest
before delete. Release copy now says npm/PowerShell create junctions and
Go uses os.Symlink, with copy fallback when linking is unavailable.
2026-08-18 15:53:56 +08:00
pengzhihan47-star 97e5ded043 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 13:49:34 +08:00
玉澜 71da2dfded Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 13:48:35 +08:00
玉澜 21395ed12d fix(skills): retract unrecorded dest after cross-device publish
Cross-filesystem Skill moves now record publication identity as soon
as the staging path is renamed onto dest. A later mode-restore, copy
verification, or staging-cleanup failure retracts that proven dest so
retries are not blocked by an untracked leftover. A failed retract
reports an uncertain state naming both retained locations.
2026-08-18 13:48:23 +08:00
玉澜 5f2344d16d fix(skills): claim shell copy publications atomically and verify rollback identity
The shell mono/multi set publishers staged each Skill directory and
published it with a plain mv after the backup; anything another process
created at the destination between the backup and the move was silently
replaced, and restore_multi_skill_set then blind-deleted manifest paths,
so a concurrently replaced object could also be destroyed during
rollback. Publish through an atomic mkdir claim instead — EEXIST refuses
any occupant, staged children move into the claim one by one, and a
failed child move relocates them and removes only the claim. The
published manifest now records <dest>:<inode>, and rollback deletes a
destination only when its inode still matches the publication, skipping
concurrently replaced paths with a warning. Also fixes a latent
unbound-variable expansion where a shell variable was followed directly
by a full-width parenthesis in a message. Regression tests publish a
first Skill, replace it with a foreign directory, fail the second
publication, and assert rollback retains the foreign object untouched
while restoring the rest from backups.
2026-08-18 13:00:03 +08:00
pengzhihan47-star 7ceeafbae8 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 12:27:08 +08:00
玉澜 e79efc70af Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 12:00:32 +08:00
pengzhihan47-star 54b4a24a14 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 11:35:30 +08:00
玉澜 7fa4ee7bac docs(skills): describe the actual degraded no-replace publication
The RFC section on filesystems that reject the atomic no-replace rename
still described the retired existence-check-plus-plain-rename fallback
and its accepted race window. The implementation (and the npm and shell
surfaces) claim the destination with mkdir or a hard link — or create
the link directly at the destination — and never release the claim mid
transaction, so a concurrently created object is refused rather than
overwritten. Record that contract and its only relaxed property (child
moves are not all-or-nothing visible) so future maintainers do not
port the racy description back into code.
2026-08-18 11:26:37 +08:00
恋川 5b9234d8fe fix(recruit): align job creation contract 2026-08-18 11:16:04 +08:00
恋川 619319517a Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-18 11:08:45 +08:00
玉澜 74513bae2f Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 10:57:19 +08:00
玉澜 fcbddb0904 fix(skills): create shell-published links at the destination atomically
The POSIX shell installers staged shared Skill links and published them
with mv after an existence check; a file or symlink another process
created at the destination between the check and the move was silently
replaced, and the inode confirmation could not detect the loss. Publish
by creating each link directly at its destination instead — symlink(2)
refuses an occupied path with EEXIST, so the creation itself is the
atomic no-replace check. A directory that appears at the destination
turns ln -s into a container; the nested link is removed after an
identity check and the transaction rolls back, leaving the foreign
directory untouched. Applied to install.sh, install-skills.sh,
install-event.sh, and install-devapp.sh. Also covers the remaining
retraction branches of the Go shell-removal fallback so changed-code
coverage is complete. Regression tests inject a concurrent occupant at
the publish instant for regular-file and directory cases and assert the
foreign object and its contents stay completely unchanged.
2026-08-18 10:20:15 +08:00
pengzhihan47-star e1bfb343f4 Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 10:13:27 +08:00
柏智 7da423bf3c docs(skill): clarify import and recent document routes 2026-08-18 09:37:47 +08:00
玉澜 e84743615f fix(skills): retract a child move when the source shell cannot be removed
On filesystems without atomic no-replace rename, the degraded
publication moves the source children into a fresh claim and leaves an
emptied source shell for the caller to remove once the move is
confirmed. If that removal failed, moveSkillPathRecoverably reported a
plain failure claiming both locations were preserved while the data
existed only at the destination, so backupAndRemoveSkillDir never
recorded the backup and the original path was left empty. Move the
children back into the shell and withdraw the destination instead; a
failed retraction reports the data location explicitly. Restores the
contract that a failed move keeps the source intact.
2026-08-18 09:30:19 +08:00
柏智 fa83ee579c docs(skill): remove lark-specific wording 2026-08-18 08:25:06 +08:00
玉澜 a102447eb5 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 08:20:15 +08:00
玉澜 2b131a1031 fix(skills): create canonical links at the destination atomically
publishCanonicalLinkNoReplace checked the destination with lstat and
then published, leaving a window the comment claimed did not exist: on
Windows renameSync replaces a concurrent object outright (libuv passes
MOVEFILE_REPLACE_EXISTING), and on POSIX ln -P source target links INTO
a directory that appeared at the target, leaving a stray link inside
foreign data that the rollback list never recorded. Create the symlink
or junction directly at the destination instead — link creation fails
with EEXIST when anything occupies the path and never treats the target
as a container, so the publication itself is the atomic no-replace
check. Identity confirmation re-reads the live link before the
publication enters the rollback list. Covered by injected concurrent
creators at the publish instant on POSIX and simulated Windows,
asserting the foreign object and its contents stay completely
unchanged.
2026-08-18 08:17:46 +08:00
pengzhihan47-star 25c694aa2a Merge branch 'main' into codex/dingtalk-doc-skill-opt-v1 2026-08-18 07:46:06 +08:00
柏智 e064d394ba docs(skill): optimize dingtalk doc workflows 2026-08-18 01:02:37 +08:00
玉澜 5fdaea5f36 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-18 00:43:13 +08:00
玉澜 e8a320a06b fix(skills): claim npm copy publish destinations atomically
The mono and multi set copy publishers checked destination existence
with lstat and then called Node's rename, which replaces the target on
every platform (libuv passes MOVEFILE_REPLACE_EXISTING on Windows). A
file, symlink, or empty directory created between the check and the
rename was silently overwritten, and the identity confirmation could not
recover it because the publication record only proved the staged object
arrived. Claim the destination with mkdir — which fails with EEXIST if
anything occupies the path, so the claim itself is the existence check —
and move the staged children into the claim, restoring the source mode
on it. A failed child move relocates the children back and removes only
the claim. Covered for mono, multi, and simulated Windows, including an
injected concurrent creator at the claim instant.
2026-08-18 00:24:18 +08:00
玉澜 59268a42a6 fix(skills): retract the published link when source removal fails
The no-replace file fallback links the destination and then removes the
source. If the removal fails, the caller treats the publish as failed,
but no publication record exists to roll the new destination back, and
a backup restore would refuse the occupied path. Remove the destination
behind an identity check — only the proven linked object may be deleted
— and report when the retraction itself fails or the destination was
concurrently replaced.
2026-08-17 23:51:17 +08:00
玉澜 06661af43f fix test: published file ID must differ from staged for Windows proof
The previous wrapper returned the first observed ID for both paths, so
expected == actual still held on Windows and the proof accepted the
swap. Return a distinct ID for the second probe.
2026-08-17 22:11:24 +08:00
玉澜 951dd27f0c test(skills): fake same file IDs across staged and published paths
The constant file-ID stub made both IDs equal, so the Windows proof
(expected == actual) accepted the publication and the subtest failed
there; Unix stayed green because its proof ignores the ID strings and
the swapped os.SameFile seam already forced the failure. Return the
first observed ID for both paths so staged and published identities
differ on every platform while real IDs still flow through the wrapper.
2026-08-17 22:10:37 +08:00
玉澜 bc5e5db7ef test(skills): pin publish identity rejection through the identity seam
The physical same-content swap relied on the recreated destination
getting a fresh inode, but CI runners' ext4/overlayfs recycle inodes
eagerly, so the swap was undetectable on Linux and the subtest failed
there (while passing on macOS). Swap the same-file identity seam instead
so the confirmation's fast-path rejection contract is pinned on every
platform.
2026-08-17 21:46:42 +08:00
玉澜 a0accff258 test(skills): assert claim mode matches source across platforms 2026-08-17 21:33:35 +08:00
玉澜 f7a3e606f7 fix(skills): never prune shell installers' current-run backups
The four standalone installers pruned the oldest excess stamp
directories regardless of origin, so a migration retiring more than
five batches destroyed its own rollback material mid-run — the same
data loss already fixed for Go via the run-root registry and present
in install.js/install.ps1 as currentRunBackupRoots. Every installer now
records the stamp directories it creates and pruning only removes
earlier-run batches, which is what the changelog already promises.
2026-08-17 21:24:46 +08:00
玉澜 1d1aca5fd1 test(skills): cover no-replace fallback error and rollback branches 2026-08-17 21:24:43 +08:00
恋川 b199fd29cb test(recruit): cover missing request job id 2026-08-17 20:53:32 +08:00
玉澜 93703113cb fix(skills): hold the no-replace claim instead of unlinking and retrying
The degraded directory publication claimed the destination with mkdir, then
— on platforms whose rename refuses to replace a directory (macOS refuses
even an empty target, verified empirically) — removed the claim and retried
a plain rename. Between the unlink and the retry a foreign directory could
appear at the destination and be silently overwritten, breaking the
no-replace contract the fallback exists to provide.

Hold the claim for the whole transaction instead: rename over the claim
where the platform permits it (Linux), otherwise move the source children
into the claim one by one. The destination is never unlinked, so a
concurrent creator can only ever lose the mkdir race; every child rename
targets a nonexistent path inside the empty claim, and a failed move
restores the children and removes only the claim.

The child move legitimately changes the publication's identity, which the
confirmation now handles: a rename that consumed the staged path is still
proven by identity, while a child move is proven by the pre-rename content
fingerprint. The emptied source shell doubles as the signal distinguishing
the two shapes; moveSkillPathRecoverably removes it to keep move semantics.
2026-08-17 20:21:10 +08:00
玉澜 e5ed9e6e39 fix(skills): never prune backups taken by the running migration
The backup stamp has second precision and pruning kept only the newest 5
stamps, so a canonical migration that retires copies across many Agent
roots deleted its own earlier backups mid-run. That silently voided the
reversibility guarantee the transaction depends on for rollback: a probe
retiring 8 paths lost 3 of them permanently.

Record every stamp directory this process creates, keyed by normalized
absolute path, and prune only the oldest foreign stamps.
2026-08-17 20:21:06 +08:00
玉澜 7924e84fb6 fix(skills): fall back to copy when link publication fails
Creating the staged symlink usually succeeds, so the link strategy really
fails at publish time: renameSkillPathNoReplace has no atomic no-clobber
primitive for a symlink source and refuses it whenever the kernel flag is
unavailable (NFS, FUSE, overlayfs). Gating the copy fallback on staging
alone therefore left every non-universal Agent unconfigured on exactly the
filesystems the fallback exists to support.

Retry the whole target transaction as a direct copy after a failure in any
phase, but only when the failed attempt fully restored the originals. The
converter also re-adds the replacement backups the link plan deliberately
skips for destinations already pointing at canonical, which a copy must
replace and no-replace publication would otherwise reject with EEXIST.
2026-08-17 20:21:03 +08:00
玉澜 b4129c467d test(skills): cover Windows same-file identity seam with synthetic info
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
2026-08-17 20:21:00 +08:00
玉澜 a12abdfb54 refactor(skills): collapse Windows identity error paths for coverage
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
2026-08-17 20:20:58 +08:00
玉澜 d9283b9a82 style: gofmt alignment after adding skillPathSameFileIdentity seam 2026-08-17 20:20:55 +08:00
玉澜 f1d40e26e1 fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 20:20:52 +08:00
玉澜 0db91cfc44 fix(skills): prove Windows rollback identity via stable file ID
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
2026-08-17 20:20:49 +08:00
玉澜 f88be7ae21 test(skills): cover non-EEXIST link error on Windows
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
2026-08-17 20:20:46 +08:00
玉澜 e3313095ba test(skills): cover all no-replace fallback branches for 100% coverage
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
2026-08-17 20:20:44 +08:00
玉澜 c7882d7f72 fix(skills): eliminate TOCTOU in no-replace rename fallback
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.

Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
  EEXIST if occupied). On Linux rename(2) replaces the empty dir
  directly; on Darwin/Windows rename refuses existing dirs so the empty
  dir is removed and the rename retried — any concurrent creation
  between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
  os.Remove completes the move.

Add concurrent-creation test covering the mkdir→rename race window.
2026-08-17 20:20:41 +08:00
玉澜 92196738d3 test(skills): cover Windows stat-error branch in no-replace fallback
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
2026-08-17 20:20:38 +08:00
玉澜 0a4da58d8f fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test
The smoke test creates temp home directories with .config/kimchi markers
for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the
runner's real config path, causing resolvedAgentTargets to look outside
the temp home. Unset it so detection resolves against the test's temp dir.
2026-08-17 20:20:35 +08:00
玉澜 f14332f143 fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 20:20:32 +08:00
玉澜 62883b7940 fix(skills): make obsolete-copy retirement non-fatal and harden install
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).

Also:
- Add a checked-rename fallback for filesystems that reject the atomic
  no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
  the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
  store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
  HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
  no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
2026-08-17 20:20:29 +08:00
玉澜 6e20bc765f test(skills): cover Windows no-replace path errors 2026-08-17 20:20:27 +08:00
玉澜 ecaefb416f fix(skills): retain Windows reparse link publication 2026-08-17 20:20:24 +08:00
玉澜 f16feed896 fix(skills): compare Windows publication identity stably 2026-08-17 20:20:21 +08:00
玉澜 d0a9dad079 test(skills): cover post-publish identity reuse 2026-08-17 20:20:19 +08:00
玉澜 e6c54cf777 fix(skills): distinguish reused publication inodes 2026-08-17 20:20:15 +08:00
玉澜 7a97354d93 fix(skills): make publication rollback race-safe 2026-08-17 20:20:13 +08:00
玉澜 a46958c788 fix(skills): make PowerShell rollback race-safe 2026-08-17 20:20:10 +08:00
玉澜 b664ace2f2 test(skills): junction-safe rollback and per-agent degrade regressions
- extend the silent-rollback contract to install-event.sh
- static contract: Restore-MultiSkillSet removes published paths lexically
  (section-scoped so identity-anchor refactors keep the guarantee) and link
  staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot
- install-event.sh integration test: an uninstallable agent target is
  skipped loudly while later agents still receive links
- pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and
  symlinks (junction idempotency asserted where junctions are creatable)
2026-08-17 20:20:08 +08:00
玉澜 a789a2eea7 fix(skills): junction-safe PowerShell rollback and per-agent degrade
- install.ps1: remove published junctions lexically in Restore-MultiSkillSet
  (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse
  and could delete canonical store contents); clean link staging dirs
  lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably
- install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via
  Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns
  recognize already-published junctions instead of backup churn
- install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with
  the loud backup-retained failure contract already enforced for devapp
- event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade
  per agent like install.sh (skip loudly, continue, report at the end)
  instead of aborting mid-loop or swallowing errors
- tests: junction-lexical removal contract, event per-agent degrade
  integration test, pwsh junction physical-root recognition + rerun
  idempotency (no backup churn)
2026-08-17 20:20:06 +08:00
玉澜 e4a1feccf0 test(skills): retain rollback identity anchor 2026-08-17 20:20:04 +08:00
玉澜 3f39a9ddb1 Revert "test(skills): retain rollback identity anchor"
This reverts commit ce73a5b452.
2026-08-17 20:20:02 +08:00
玉澜 b080b6e7c5 test(skills): retain rollback identity anchor 2026-08-17 20:19:59 +08:00
玉澜 dc180f6d07 fix(skills): retain link identity anchors through rollback 2026-08-17 20:19:57 +08:00
玉澜 7b64576ea1 fix(skills): protect shell link rollback from races 2026-08-17 20:19:55 +08:00
玉澜 437dd234b2 fix(skills): fail upgrade unconditionally when canonical publish fails
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.

Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.
2026-08-17 20:19:53 +08:00
玉澜 04f78bcb15 fix(skills): remove ineffective app detection gate 2026-08-17 20:19:50 +08:00
玉澜 9abcdb4deb Revert "fix(skills): make app-bundle detection gate HOME-independent"
This reverts commit 37cd629335.
2026-08-17 20:19:48 +08:00
玉澜 c0da89e674 fix(skills): make app-bundle detection gate HOME-independent
The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in
production: systemHome came from os.UserHomeDir, which honors the $HOME env
override just like homeDir, so the two were always equal and the gate never
fired when $HOME was overridden.

ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix),
which is independent of $HOME, falling back to $HOME only when the user record
cannot be resolved. Production behavior is unchanged (a real $HOME still
matches); an isolated/overridden HOME now correctly skips machine-wide
/Applications discovery for zcode/minimax. The app surface references the same
shared resolver.

This is the correct fix for the hermeticity concern (machine-wide state leaking
into an isolated HOME): there is no cross-surface production inconsistency to
port — script installers always operate on the real user HOME in practice, so
they need no gate.
2026-08-17 20:19:46 +08:00
玉澜 09fc5d993d test(skills): cover Windows chmod failure branch 2026-08-17 20:19:44 +08:00
玉澜 8f3a9e9d4a test(skills): cover Windows permission preparation seams 2026-08-17 20:19:41 +08:00
玉澜 567ea5d77c test(skills): make mode checks portable on Windows 2026-08-17 20:19:39 +08:00
玉澜 fc18f8fd04 fix(skills): preserve read-only backup trees 2026-08-17 20:19:37 +08:00
玉澜 a39ad4e6af fix(skills): make backups cross-filesystem safe 2026-08-17 20:19:34 +08:00
玉澜 301429e3aa test(ci): cover canonical skill platform branches 2026-08-17 20:19:32 +08:00
玉澜 0af5751d75 fix(skills): harden canonical agent installation 2026-08-17 20:19:30 +08:00
玉澜 79f7ee80e0 fix(skills): complete canonical agent compatibility 2026-08-17 20:19:28 +08:00
玉澜 44d640bbae fix(skills): use canonical global installation 2026-08-17 20:19:25 +08:00
恋川 06b4f3ba31 merge main into feat/recruit-job to #85340676 2026-08-17 20:00:13 +08:00
恋川 9f983be1ac fix(recruit): validate job response identity to #85340676 2026-08-17 19:55:12 +08:00
恋川 80bca147e0 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-17 17:21:31 +08:00
恋川 2cea069f55 fix(recruit): scope lossless number decoding to #85340676 2026-08-17 16:09:43 +08:00
恋川 208a6c0273 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-17 15:28:04 +08:00
玉澜 4e8469a175 test(skills): cover Windows same-file identity seam with synthetic info
skillPathSameFileIdentityImpl on Windows always returns false and is
never reached through skillPathIdentityProven (which uses file IDs
exclusively). Add a direct seam call with synthetic os.FileInfo to
exercise the Windows return-false path and the Unix os.SameFile path
with nil Sys().
2026-08-17 15:25:32 +08:00
恋川 b7a07abcb1 test(recruit): cover cursor through response pipeline to #85340676 2026-08-17 15:14:22 +08:00
玉澜 2292a49c6a refactor(skills): collapse Windows identity error paths for coverage
Restructure skillPathFileIdentityImpl to use nested if-err-nil with a
named return and skillPathIdentityProven to use a single expression.
Error conditions now fall through to the bare return instead of
occupying separate coverage blocks, eliminating 5 uncovered statements
that the Windows coverage gate flagged at 99.4193%.
2026-08-17 15:00:25 +08:00
玉澜 cf43cf1b47 style: gofmt alignment after adding skillPathSameFileIdentity seam 2026-08-17 14:41:55 +08:00
玉澜 344104268a fix(skills): open reparse points in Windows file ID query and stabilize tunneled test
Add FILE_FLAG_OPEN_REPARSE_POINT to the Windows CreateFile call in
skillPathFileIdentityImpl so symlinks are opened as reparse points
rather than followed to their target. Staged symlinks carry relative
targets computed for the final destination, which may not resolve from
the staging directory; following them caused CreateFile to fail,
yielding an empty file ID that rejected publication and broke canonical
skill layout migration on Windows.

Make skillPathSameFileIdentity a seam variable so the tunneled
replacement test can deterministically simulate the identity change on
Unix. On tmpfs (used by Linux CI runners), os.SameFile can return true
for a recreated file due to inode reuse, making the test flaky. On
Windows the swap is a no-op because skillPathIdentityProven compares
file IDs from GetFileInformationByHandle and ignores
skillPathSameFileIdentity.
2026-08-17 14:39:59 +08:00
恋川 89027aa2e2 fix(recruit): distinguish business failures and unwrap once to #85340676 2026-08-17 14:05:14 +08:00
玉澜 e45608bb13 fix(skills): prove Windows rollback identity via stable file ID
NTFS file tunneling can restore the original creation time for a
recreated same-named object, which defeated the creation-time
incarnation check and allowed rollback to delete a concurrent
replacement. Replace the platform-specific identity pair with a single
skillPathIdentityProven function:

- Unix: delegates to os.SameFile (inode/dev), ignoring file ID strings
- Windows: compares VolumeSerialNumber:FileIndexHigh:FileIndexLow from
  GetFileInformationByHandle, which uniquely identifies the file on the
  volume for its lifetime and is unaffected by tunneling

When the file ID cannot be obtained at publish time, identity is not
proven and the auto-delete is refused. Add a regression test that
simulates tunneled creation time and verifies rollback still refuses
the concurrent replacement.
2026-08-17 14:03:28 +08:00
玉澜 ff6e2347f6 test(skills): cover non-EEXIST link error on Windows
On Windows isNoReplaceRenameUnsupported always returns false, so the
fallback is never entered from the invalid-path test. Force the fallback
and swap skillPathLink to a non-EEXIST error to cover line 94 on all
platforms.
2026-08-17 13:39:12 +08:00
玉澜 2d29f6601b test(skills): cover all no-replace fallback branches for 100% coverage
Add tests for mkdir non-EEXIST error, remove failure after rename
failure, first-rename-succeeds path (Linux behavior), retry-rename
path, and non-regular source safe-fail. All 24 changed executable
statements now covered on both macOS and Windows.
2026-08-17 13:22:58 +08:00
玉澜 fa887ccd26 fix(skills): eliminate TOCTOU in no-replace rename fallback
The fallback path for filesystems without RENAME_NOREPLACE/EXCL (NFS,
FUSE, overlayfs) used Lstat-then-Rename, which could overwrite a
concurrently created destination between the check and the rename.

Replace the TOCTOU-prone check with truly atomic no-clobber primitives:
- Directories: os.Mkdir atomically claims the destination (fails with
  EEXIST if occupied). On Linux rename(2) replaces the empty dir
  directly; on Darwin/Windows rename refuses existing dirs so the empty
  dir is removed and the rename retried — any concurrent creation
  between remove and rename is detected by the second rename failing.
- Files: os.Link atomically fails if the destination exists, then
  os.Remove completes the move.

Add concurrent-creation test covering the mkdir→rename race window.
2026-08-17 13:11:15 +08:00
玉澜 30202e2b81 Merge remote-tracking branch 'origin/main' into fix/canonical-agent-skills 2026-08-17 12:30:34 +08:00
玉澜 1203409185 test(skills): cover Windows stat-error branch in no-replace fallback
The !os.IsNotExist(statErr) branch in renameSkillPathNoReplace was
uncovered on Windows. Inject errNoReplaceRenameUnsupported for the
atomic rename and os.ErrPermission for skillPathLstat so the stat-error
path is exercised on every platform.
2026-08-17 12:23:40 +08:00
玉澜 0ba55350d8 fix(ci): unset XDG_CONFIG_HOME for npm installer smoke test
The smoke test creates temp home directories with .config/kimchi markers
for agent detection. On Linux CI runners XDG_CONFIG_HOME may point to the
runner's real config path, causing resolvedAgentTargets to look outside
the temp home. Unset it so detection resolves against the test's temp dir.
2026-08-17 11:48:11 +08:00
玉澜 14c2569cfb fix(skills): guard pruneSkillBackups against non-DWS directories
Restrict backup pruning to directories whose names match the DWS stamp
format (YYYYmmdd-HHMMSS with optional -N suffix) across all 8 installer
surfaces (Go, npm, 4 shell, 2 PowerShell). Unknown directories in
~/.dws/skill-backups are now preserved. Also fixes Windows coverage test
portability and covers the remaining macOS changed-code gap (retire
warning loop in runUpgrade).
2026-08-17 11:42:43 +08:00
john ae1565c0ff Merge branch 'main' into fix/canonical-agent-skills 2026-08-17 10:23:34 +08:00
玉澜 7581955892 fix(skills): make obsolete-copy retirement non-fatal and harden install
A universal Agent whose obsolete private copy cannot be retired installs
nothing there, yet every entry point counted that retirement failure as an
install failure — aborting `npm install`, `dws skill setup`, and the shell
installers even when the canonical store and all links published correctly,
and skipping the skills-state write. Route retirement failures to a separate
warning path across all surfaces (Go upgrade + skill setup, npm, PowerShell,
install.sh, install-skills.sh, install-event.sh, install-devapp.sh).

Also:
- Add a checked-rename fallback for filesystems that reject the atomic
  no-replace flag (NFS, FUSE, overlayfs); the no-clobber contract is kept and
  the previously unsupported platforms build and work.
- PowerShell multi-mode links only bundle skills, never the shared canonical
  store, so third-party/user skills are no longer fanned into every Agent root.
- Prune ~/.dws/skill-backups to the newest 5 on every surface; encode
  HOME-relative backup names on PowerShell to preserve origin.
- Add simulated-win32 junction coverage and rewrite the tautological
  no-replace test; remove dead code whose tests gave false coverage.
- Soften the overstated Windows ownership-proof comment (NTFS tunneling).
2026-08-15 14:26:11 +08:00
玉澜 24bbdda423 test(skills): cover Windows no-replace path errors 2026-08-14 20:22:32 +08:00
玉澜 276658590b fix(skills): retain Windows reparse link publication 2026-08-14 20:14:04 +08:00
玉澜 16d20b8178 fix(skills): compare Windows publication identity stably 2026-08-14 20:07:33 +08:00
玉澜 dd89c67f4d Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 20:00:47 +08:00
玉澜 75bf44b7be test(skills): cover post-publish identity reuse 2026-08-14 19:58:14 +08:00
玉澜 1bae872341 fix(skills): distinguish reused publication inodes 2026-08-14 19:47:04 +08:00
玉澜 d1ecdcd551 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 19:24:09 +08:00
玉澜 a47740ca1c fix(skills): make publication rollback race-safe 2026-08-14 19:23:59 +08:00
玉澜 4ad321557f Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 18:04:34 +08:00
玉澜 34dee96833 fix(skills): make PowerShell rollback race-safe 2026-08-14 17:51:12 +08:00
玉澜 fc455f800c test(skills): junction-safe rollback and per-agent degrade regressions
- extend the silent-rollback contract to install-event.sh
- static contract: Restore-MultiSkillSet removes published paths lexically
  (section-scoped so identity-anchor refactors keep the guarantee) and link
  staging dirs are cleaned via Remove-LinkStageRoot / Remove-DevLinkStageRoot
- install-event.sh integration test: an uninstallable agent target is
  skipped loudly while later agents still receive links
- pwsh probe: Test-SamePhysicalSkillRoot must dereference junctions and
  symlinks (junction idempotency asserted where junctions are creatable)
2026-08-14 17:14:13 +08:00
玉澜 3556d28fdd fix(skills): junction-safe PowerShell rollback and per-agent degrade
- install.ps1: remove published junctions lexically in Restore-MultiSkillSet
  (Windows PowerShell 5.1 follows reparse points during Remove-Item -Recurse
  and could delete canonical store contents); clean link staging dirs
  lexically in Publish-CanonicalSkillLinks and Move-SkillPathRecoverably
- install.ps1: Test-SamePhysicalSkillRoot now dereferences junctions via
  Get-PhysicalSkillPath (mirrors EvalSymlinks/realpathSync/cd -P), so reruns
  recognize already-published junctions instead of backup churn
- install-event.sh: replace silent 'mv ... 2>/dev/null || true' rollback with
  the loud backup-retained failure contract already enforced for devapp
- event/devapp sh+ps1: link→copy fallback and per-agent failures now degrade
  per agent like install.sh (skip loudly, continue, report at the end)
  instead of aborting mid-loop or swallowing errors
- tests: junction-lexical removal contract, event per-agent degrade
  integration test, pwsh junction physical-root recognition + rerun
  idempotency (no backup churn)
2026-08-14 16:48:34 +08:00
玉澜 618eb842a2 test(skills): retain rollback identity anchor 2026-08-14 16:11:48 +08:00
玉澜 465acf1406 Revert "test(skills): retain rollback identity anchor"
This reverts commit ce73a5b452.
2026-08-14 16:11:13 +08:00
玉澜 ce73a5b452 test(skills): retain rollback identity anchor 2026-08-14 16:09:24 +08:00
玉澜 175b51cec0 fix(skills): retain link identity anchors through rollback 2026-08-14 15:45:51 +08:00
玉澜 53a71b08c7 fix(skills): protect shell link rollback from races 2026-08-14 15:35:25 +08:00
玉澜 3ef735bb3c Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 14:44:08 +08:00
恋川 44e18a4062 Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-14 14:37:05 +08:00
恋川 ccbacf84b3 test(helpers): cover trailing MCP JSON responses 2026-08-14 14:36:49 +08:00
玉澜 7cfaa1ca74 fix(skills): fail upgrade unconditionally when canonical publish fails
A failed canonical publish only failed the upgrade when
hasDependentSkillRoot reported a non-universal link target; that helper
explicitly skipped universal agents, which are exactly the direct consumers
of ~/.agents/skills. On a universal-only machine (e.g. only Codex
installed), UpgradeSkillLocations* returned a nil error with nothing
installed, contradicting the documented "canonical publication is
mandatory and fails the upgrade loudly" contract.

Canonical publish failures now return an error unconditionally in both the
mono and multi branches, and hasDependentSkillRoot is removed. The test
that pinned the old standalone-does-not-fail-fast behavior now asserts
error propagation in both modes.
2026-08-14 14:27:57 +08:00
恋川 d8f8f29062 fix(recruit): unwrap connector result envelopes 2026-08-14 14:06:08 +08:00
玉澜 f8af8dc1dc Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 12:04:50 +08:00
玉澜 bb6fd2f256 fix(skills): remove ineffective app detection gate 2026-08-14 11:58:27 +08:00
玉澜 580c4d201b Revert "fix(skills): make app-bundle detection gate HOME-independent"
This reverts commit 37cd629335.
2026-08-14 11:43:57 +08:00
恋川 4f754133a5 fix(recruit): align pagination and size contracts 2026-08-14 11:43:39 +08:00
玉澜 37cd629335 fix(skills): make app-bundle detection gate HOME-independent
The allowSystemApps gate (homeDir == systemHome) was effectively a no-op in
production: systemHome came from os.UserHomeDir, which honors the $HOME env
override just like homeDir, so the two were always equal and the gate never
fired when $HOME was overridden.

ResolveSystemHomeDir now prefers the OS user database (getpwuid on Unix),
which is independent of $HOME, falling back to $HOME only when the user record
cannot be resolved. Production behavior is unchanged (a real $HOME still
matches); an isolated/overridden HOME now correctly skips machine-wide
/Applications discovery for zcode/minimax. The app surface references the same
shared resolver.

This is the correct fix for the hermeticity concern (machine-wide state leaking
into an isolated HOME): there is no cross-surface production inconsistency to
port — script installers always operate on the real user HOME in practice, so
they need no gate.
2026-08-14 11:33:55 +08:00
恋川 b447aac84b Merge remote-tracking branch 'upstream/main' into feat/recruit-job 2026-08-14 11:31:33 +08:00
恋川 9703a21a2d fix(recruit): normalize connector list response 2026-08-14 10:43:50 +08:00
玉澜 0c0e2b3ce1 test(skills): cover Windows chmod failure branch 2026-08-14 10:32:20 +08:00
玉澜 7d16c9693f test(skills): cover Windows permission preparation seams 2026-08-14 10:22:03 +08:00
玉澜 1dee02d900 test(skills): make mode checks portable on Windows 2026-08-14 10:08:40 +08:00
玉澜 7664f04fda fix(skills): preserve read-only backup trees 2026-08-14 08:50:14 +08:00
玉澜 8177a06296 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-14 08:43:04 +08:00
玉澜 c674366aea fix(skills): make backups cross-filesystem safe 2026-08-14 08:42:54 +08:00
chichuan ce5815a606 Merge branch 'main' into fix/canonical-agent-skills 2026-08-13 22:01:48 +08:00
玉澜 d211b79a80 test(ci): cover canonical skill platform branches 2026-08-13 21:49:35 +08:00
玉澜 de8df0fa6f fix(skills): harden canonical agent installation 2026-08-13 21:22:57 +08:00
玉澜 9ff31cdd55 Merge remote-tracking branch 'upstream/main' into fix/canonical-agent-skills 2026-08-13 20:23:05 +08:00
玉澜 fde37f7896 fix(skills): complete canonical agent compatibility 2026-08-13 20:21:56 +08:00
玉澜 31902a987e fix(skills): use canonical global installation 2026-08-13 18:50:27 +08:00
恋川 5ef52503ae fix(recruit): align result and cursor contracts 2026-08-13 15:37:49 +08:00
恋川 8ee9fc3f48 fix: 补充招聘结果与分页契约 to#85340676 2026-08-13 13:50:18 +08:00
恋川 19e19bcd2b feat: 新增招聘职位管理 to#85340676 2026-08-13 10:26:03 +08:00
262 changed files with 43244 additions and 6414 deletions
+5
View File
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,5 @@
---
category: Added
---
- **招聘职位管理** (#976) — 新增招聘职位列表、详情查询和职位创建命令。
File diff suppressed because one or more lines are too long
@@ -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.
@@ -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.
@@ -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.
@@ -0,0 +1,5 @@
---
category: Fixed
---
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
@@ -0,0 +1,5 @@
---
category: Added
---
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
+41 -5
View File
@@ -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
@@ -611,7 +617,13 @@ jobs:
- name: Install archive tooling
if: ${{ matrix.shard == 'release-scripts' && steps.select.outputs.affected == 'true' }}
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test shard with Race Detection
if: ${{ steps.select.outputs.affected == 'true' }}
@@ -751,7 +763,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Test release scripts
shell: bash
@@ -1129,7 +1147,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Build
run: make build
@@ -1180,7 +1204,13 @@ jobs:
go-version-file: go.mod
- name: Install archive tooling
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run policy and shortcut coverage
run: |
@@ -1261,7 +1291,13 @@ jobs:
- name: Install archive tooling
if: needs.lint.outputs.full_suite == 'true' && steps.baseline-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y zip unzip
run: |
if command -v zip >/dev/null && command -v unzip >/dev/null; then
echo "zip and unzip are already available"
else
sudo apt-get update
sudo apt-get install -y zip unzip
fi
- name: Run baseline unit tests with coverage
if: steps.baseline-cache.outputs.cache-hit != 'true'
+4 -2
View File
@@ -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
+58
View File
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -74,9 +74,9 @@ coverage is additionally selected for platform-sensitive code.
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
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=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
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
+11 -11
View File
@@ -1,33 +1,33 @@
class DingtalkWorkspaceCliBeta < Formula
desc "Automate DingTalk workspace tasks from the terminal (beta channel)"
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
version "1.0.59-beta.3"
version "1.0.59-beta.5"
license "Apache-2.0"
keg_only "it is the beta channel and conflicts with dingtalk-workspace-cli"
on_macos do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-arm64.tar.gz"
sha256 "274d56599a8e33ccca86a139424cab95a54ba311d6b643bccb2d3e6608cd16b4"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-darwin-amd64.tar.gz"
sha256 "0a0a00a77ca24c102204cd6b7de3de58f406a7e0fad2dd965a4c1fe903c34f39"
end
end
on_linux do
if Hardware::CPU.arm?
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-arm64.tar.gz"
sha256 "1e7af6393979c2fa433af9207722989749f11ea8e09ff9bcd5696e505d6d7f88"
else
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-linux-amd64.tar.gz"
sha256 "4896e71a1417acc3d8834fa99f0e81511e020ff952e57c0562cc255e90acec80"
end
end
resource "skills" do
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.5/dws-skills.zip"
sha256 "11000b9c3566e3b38e3037b6b3069d55c8f50725b3ed9cd67714a7ebb794cd47"
end
def install
+11 -11
View File
@@ -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
+2 -2
View File
@@ -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).
+1214 -126
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -32,6 +32,6 @@
"README.md"
],
"engines": {
"node": ">=16"
"node": ">=16.7.0"
}
}
+60 -4
View File
@@ -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]")
}
+123
View File
@@ -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 {
+5 -5
View File
@@ -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:
+62 -4
View File
@@ -1,7 +1,9 @@
# CLI flag 兼容迁移治理
# CLI Help / Schema 兼容迁移治理
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
同一套 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
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
@@ -31,7 +33,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,10 +41,66 @@ 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`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
## 跨命令迁移原语
`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)` 为唯一精确键,并经历以下生命周期:
@@ -52,9 +110,9 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
+6 -3
View File
@@ -3,7 +3,7 @@
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
- **Products**: 13
- **Total commands**: 160
- **Total commands**: 163
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
_Group chats, conversations, messages, and robot/webhook integrations._
**23 commands**
**26 commands**
| Command | Description | When to use |
|---|---|---|
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
+3 -3
View File
@@ -23,7 +23,7 @@
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
dws-release v1.2.3-beta.1
```
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
## 正式发布
@@ -147,7 +147,7 @@ fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
+57 -6
View File
@@ -54,8 +54,8 @@ DWS 对任何外部实现的持续兼容义务。后续设计以 DWS 自身约
| 模式 | Agent 目录布局 | 选择方式 |
|---|---|---|
| multi(默认) | `<agent-home>/dingtalk-*/` 与必选 `dingtalk-shared/` | 默认;`dws skill setup --mode multi` |
| mono(兼容) | `<agent-home>/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/<UTC 时间戳>/...`;
- 主要操作:同一文件系统内使用 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. 后续演进
@@ -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` 全部历史。
@@ -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` 全部历史。
+130 -204
View File
@@ -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",
@@ -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) {
+35
View File
@@ -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)
}
}
}
+28
View File
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
return `{"success":true,"result":[]}`
case "list_suggested_event_times":
return `{"success":true,"result":{"recommendEventTimes":[]}}`
case "list_by_keyword_and_time_range":
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
case "get_minutes_basic_info":
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
case "get_minutes_transcription":
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
case "create_personal_todo":
return `{"success":true,"result":{"taskId":"task-1"}}`
case "get_todo_detail":
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
case "get_user_todos_in_current_org":
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
case "add_todo_reminder":
return `{"success":true}`
case "copy_document":
return `{"success":true,"nodeId":"copy-1"}`
case "move_document", "add_member", "update_member", "remove_member":
return `{"success":true}`
case "get_document_info":
if len(c.calls) > 1 {
switch c.calls[len(c.calls)-2].tool {
case "copy_document":
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
case "move_document":
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
}
}
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
case "create_calendar_event":
return `{"success":true,"result":{"eventId":"event-1"}}`
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
@@ -226,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.
@@ -531,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
@@ -600,6 +674,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
}
// Candidate representatives exercise the final transport boundary for each
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
// exists in the loaded reviewed table, so the tests are mergeable before the
// joint draft is promoted to internal/cli/param_concepts.json.
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
paramAliasPayloadCaseKey("todo comment add", "text"): true,
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
paramAliasPayloadCaseKey("todo task update", "status"): true,
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
}
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
// separate from the long-lived app-c race process. Each case still executes
// both canonical and alias argv through the real PreParse/Cobra path and
@@ -709,6 +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 {
@@ -717,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)
@@ -729,8 +830,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
continue
}
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
if !paramAliasRepresentativePayloadCases[caseKey] {
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
continue
}
executedRepresentatives[caseKey] = true
@@ -742,26 +842,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
if activeCases == 0 {
t.Fatal("reviewed fixture contains no active alias cases")
}
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
for command := range paramAliasCompleteCommands {
if !activeCommands[command] {
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
}
templateCommands[command] = true
}
for command := range paramAliasCandidateCompleteCommands {
if activeCommands[command] {
templateCommands[command] = true
}
}
for command := range activeCommands {
if _, ok := paramAliasCompleteCommands[command]; !ok {
if !templateCommands[command] {
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
}
}
if len(activeCommands) != len(paramAliasCompleteCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
if len(activeCommands) != len(templateCommands) {
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
}
for caseKey := range paramAliasRepresentativePayloadCases {
if !executedRepresentatives[caseKey] {
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
}
}
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
if !activeFixtureCases[caseKey] {
continue
}
activeRepresentatives++
if !executedRepresentatives[caseKey] {
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
}
}
if len(executedRepresentatives) != activeRepresentatives {
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
}
}
@@ -1091,7 +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)
@@ -1173,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
}
@@ -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
+3
View File
@@ -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",
@@ -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"])
}
})
}
}
+56 -5
View File
@@ -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)
+128 -26
View File
@@ -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.
+40 -4
View File
@@ -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 <name>", home)
}
+459 -125
View File
@@ -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 → <agent-home>/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...) {
@@ -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
}
+395
View File
@@ -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")
}
}
@@ -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")
@@ -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)
}
})
}
+35 -5
View File
@@ -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)
}
+24 -36
View File
@@ -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)
@@ -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)
}
}
+15 -16
View File
@@ -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)
}
+3
View File
@@ -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("✓"))
}
+9 -3
View File
@@ -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" {
+3 -3
View File
@@ -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",
@@ -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.",
},
}
+1 -1
View File
@@ -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"},
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+35 -4
View File
@@ -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.
+98 -2
View File
@@ -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) {
@@ -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
}
@@ -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)
}
}
+366 -3
View File
@@ -7,6 +7,8 @@ import (
"io"
"math"
"os"
"reflect"
"sort"
"strconv"
"strings"
"time"
@@ -126,6 +128,83 @@ func resolveWorkflowDSL(cmd *cobra.Command) (map[string]any, error) {
return dsl, nil
}
// executeAitableWorkflowPublish executes a publish exactly once, then requires
// the reviewed valid/flowId envelope before rendering any success output.
// create_workflow is non-idempotent, so transport uncertainty is never retried.
func executeAitableWorkflowPublish(toolName string, args map[string]any) error {
if deps.Caller.DryRun() {
return callMCPToolOnServer("aitable", toolName, args)
}
raw, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", toolName, args)
if err != nil {
return err
}
var envelope map[string]any
if err := json.Unmarshal([]byte(raw), &envelope); err != nil || envelope == nil {
return fmt.Errorf("%s response is not a JSON object", toolName)
}
valid, validFound, flowID, err := strictAitableWorkflowPublishResult(envelope)
if err != nil {
return fmt.Errorf("%s response validation failed: %w", toolName, err)
}
if !validFound {
return fmt.Errorf("%s response is missing valid", toolName)
}
if !valid {
return fmt.Errorf("%s returned valid=false; inspect issues and correct the workflow DSL", toolName)
}
if flowID == "" {
return fmt.Errorf("%s response is missing a non-empty flowId", toolName)
}
return RenderLegacyMCPText(toolName, raw)
}
func strictAitableWorkflowPublishResult(envelope map[string]any) (valid bool, validFound bool, flowID string, err error) {
var visit func(map[string]any) error
visit = func(object map[string]any) error {
if raw, exists := object["valid"]; exists {
value, ok := raw.(bool)
if !ok {
return fmt.Errorf("valid must be boolean, got %T", raw)
}
if validFound && valid != value {
return fmt.Errorf("conflicting valid values")
}
valid, validFound = value, true
}
for _, key := range []string{"flowId", "workflowId"} {
raw, exists := object[key]
if !exists {
continue
}
value, ok := raw.(string)
value = strings.TrimSpace(value)
if !ok || value == "" {
return fmt.Errorf("%s must be a non-empty string", key)
}
if flowID != "" && flowID != value {
return fmt.Errorf("conflicting workflow IDs")
}
flowID = value
}
for _, key := range []string{"data", "result", "response"} {
if nested, ok := object[key].(map[string]any); ok {
if err := visit(nested); err != nil {
return err
}
}
}
return nil
}
if envelope == nil {
return false, false, "", fmt.Errorf("empty response")
}
if err := visit(envelope); err != nil {
return false, false, "", err
}
return valid, validFound, flowID, nil
}
func validateWorkflowRunFlags(cmd *cobra.Command, _ []string) error {
tableID, _ := cmd.Flags().GetString("table-id")
tableID = strings.TrimSpace(tableID)
@@ -1184,6 +1263,290 @@ func runAitableViewUpdateArray(cmd *cobra.Command, blockKey string) error {
return callUpdateViewWithBlock(baseID, tableID, viewID, blockKey, cfgMap[blockKey], nil)
}
func runAitableViewUpdateFilter(cmd *cobra.Command) error {
baseID, tableID, viewID, _, err := viewUpdateCommonPreflight(cmd, "filter", nil, false)
if err != nil {
return err
}
jsonStr, _ := cmd.Flags().GetString("json")
if jsonStr == "" {
return fmt.Errorf("必须指定 --json 传入 filter JSON 数组")
}
var parsed any
if err := json.Unmarshal([]byte(jsonStr), &parsed); err != nil {
return fmt.Errorf("--json 解析失败: %v", err)
}
cfgMap := map[string]any{"filter": parsed}
if err := normalizeViewConfigBlock(cfgMap); err != nil {
return err
}
filter, _ := cfgMap["filter"].([]any)
fieldTypes, err := loadAitableFieldTypes(context.Background(), baseID, tableID)
if err != nil {
return err
}
if err := validateAitableViewFilter(filter, fieldTypes); err != nil {
return err
}
toolArgs := map[string]any{
"baseId": baseID, "tableId": tableID, "viewId": viewID,
"config": map[string]any{"filter": filter},
}
if deps.Caller.DryRun() {
return deps.Out.PrintJSON(map[string]any{
"dry_run": true, "executed": false, "tool": "update_view", "arguments": toolArgs,
})
}
if _, err := callMCPToolReturnTextOnServer(context.Background(), "aitable", "update_view", toolArgs); err != nil {
return err
}
var actual any
var readBackErr error
for attempt := 0; attempt < aitableViewFilterReadbackAttempts; attempt++ {
if attempt > 0 {
backoff := time.Duration(1<<(attempt-1)) * time.Second
if backoff > 8*time.Second {
backoff = 8 * time.Second
}
aitableViewFilterReadbackSleep(backoff)
}
view, _, err := getViewRaw(context.Background(), baseID, tableID, viewID)
if err != nil {
readBackErr = err
continue
}
actualViewID, _ := view["viewId"].(string)
if actualViewID != viewID {
readBackErr = fmt.Errorf("update_view read-back returned viewId %q, want %q", actualViewID, viewID)
continue
}
actual = walkViewPath(view, "filter")
if persistedViewFilterMatches(actual, filter) {
readBackErr = nil
break
}
readBackErr = fmt.Errorf("update_view filter read-back mismatch: got %s, want %s", compactJSON(actual), compactJSON(filter))
}
if readBackErr != nil {
return &CLIError{Code: CodeMCPToolError, Message: readBackErr.Error(), Suggestion: "重新读取 view get filter,确认服务端支持所用字段类型和操作符后再试"}
}
return deps.Out.PrintJSON(map[string]any{
"success": true,
"data": map[string]any{"baseId": baseID, "tableId": tableID, "viewId": viewID, "filter": filter, "verified": true},
})
}
const aitableViewFilterReadbackAttempts = 6
var aitableViewFilterReadbackSleep = time.Sleep
func persistedViewFilterMatches(actual any, expected []any) bool {
if reflect.DeepEqual(actual, expected) {
return true
}
root, ok := actual.(map[string]any)
if !ok || root["operator"] != "and" {
return false
}
operands, ok := root["operands"].([]any)
return ok && reflect.DeepEqual(operands, expected)
}
func loadAitableFieldTypes(ctx context.Context, baseID, tableID string) (map[string]string, error) {
raw, err := callMCPReadToolReturnTextOnServer(ctx, "aitable", "get_fields", map[string]any{"baseId": baseID, "tableId": tableID})
if err != nil {
return nil, err
}
var payload any
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return nil, fmt.Errorf("get_fields response is not valid JSON: %v", err)
}
fields, ok := findAitableObjectList(payload, "fields", "fieldList")
if !ok {
return nil, fmt.Errorf("get_fields response is missing the fields collection")
}
types := make(map[string]string, len(fields))
for index, field := range fields {
fieldID, _ := field["fieldId"].(string)
if strings.TrimSpace(fieldID) == "" {
fieldID, _ = field["id"].(string)
}
fieldType, _ := field["type"].(string)
if strings.TrimSpace(fieldType) == "" {
fieldType, _ = field["fieldType"].(string)
}
if strings.TrimSpace(fieldID) == "" || strings.TrimSpace(fieldType) == "" {
return nil, fmt.Errorf("get_fields field %d is missing fieldId or type", index)
}
types[strings.TrimSpace(fieldID)] = strings.TrimSpace(fieldType)
}
return types, nil
}
func findAitableObjectList(value any, names ...string) ([]map[string]any, bool) {
wanted := make([]string, 0, len(names))
seen := make(map[string]bool, len(names))
for _, name := range names {
name = strings.ToLower(name)
if !seen[name] {
wanted = append(wanted, name)
seen[name] = true
}
}
var walk func(any) ([]map[string]any, bool)
walk = func(current any) ([]map[string]any, bool) {
switch typed := current.(type) {
case map[string]any:
keys := make([]string, 0, len(typed))
for key := range typed {
keys = append(keys, key)
}
sort.Strings(keys)
for _, name := range wanted {
for _, key := range keys {
if !strings.EqualFold(key, name) {
continue
}
items, ok := typed[key].([]any)
if !ok {
return nil, false
}
out := make([]map[string]any, 0, len(items))
for _, item := range items {
object, ok := item.(map[string]any)
if !ok {
return nil, false
}
out = append(out, object)
}
return out, true
}
}
for _, key := range keys {
if found, ok := walk(typed[key]); ok {
return found, true
}
}
case []any:
for _, child := range typed {
if found, ok := walk(child); ok {
return found, true
}
}
}
return nil, false
}
return walk(value)
}
func validateAitableViewFilter(filter []any, fieldTypes map[string]string) error {
for index, raw := range filter {
condition, ok := raw.(map[string]any)
if !ok {
return fmt.Errorf("filter[%d] must be an object", index)
}
if err := validateAitableViewFilterCondition(condition, fieldTypes); err != nil {
return fmt.Errorf("filter[%d]: %w", index, err)
}
}
return nil
}
func validateAitableViewFilterCondition(condition map[string]any, fieldTypes map[string]string) error {
operator, _ := condition["operator"].(string)
operator = strings.TrimSpace(operator)
if operator == "" || !validFilterOperators[operator] {
return fmt.Errorf("unsupported operator %q", operator)
}
operands, ok := condition["operands"].([]any)
if !ok {
return fmt.Errorf("operator %s requires an operands array", operator)
}
if operator == "and" || operator == "or" {
return fmt.Errorf("logical operator %s is not supported by the persisted view protocol; pass a flat array of leaf conditions (combined as AND)", operator)
}
wantOperands := 2
if operator == "exist" || operator == "un_exist" {
wantOperands = 1
}
if len(operands) != wantOperands {
return fmt.Errorf("operator %s requires %d operands", operator, wantOperands)
}
fieldID, ok := operands[0].(string)
fieldID = strings.TrimSpace(fieldID)
if !ok || fieldID == "" {
return fmt.Errorf("operator %s requires a fieldId as its first operand", operator)
}
fieldType, exists := fieldTypes[fieldID]
if !exists {
return fmt.Errorf("filter references unknown fieldId %q", fieldID)
}
if isAitableDateFieldType(fieldType) && !isAitableDateFilterOperator(operator) {
return fmt.Errorf("operator %s is invalid for %s field %s; use date_eq/before/after/not_before/not_after/exist/un_exist", operator, fieldType, fieldID)
}
if operator == "any_of" || operator == "all_of" || operator == "none_of" {
if !strings.EqualFold(fieldType, "multipleSelect") && !strings.EqualFold(fieldType, "multiSelect") {
return fmt.Errorf("operator %s requires a multipleSelect field, got %s", operator, fieldType)
}
if operator == "any_of" {
if values, ok := operands[1].([]any); ok {
if err := validateAitableMultiSelectOptionNames(values); err != nil {
return err
}
return fmt.Errorf("multipleSelect any_of with multiple values is not supported by the persisted view protocol; use one scalar option or separate views")
}
}
if err := validateAitableMultiSelectFilterValue(operands[1]); err != nil {
return err
}
}
return nil
}
func validateAitableMultiSelectOptionNames(values []any) error {
if len(values) == 0 {
return fmt.Errorf("multipleSelect any_of array must not be empty")
}
for index, value := range values {
text, ok := value.(string)
text = strings.TrimSpace(text)
if !ok || text == "" {
return fmt.Errorf("multipleSelect any_of value %d must be a non-empty option-name string", index)
}
}
return nil
}
func isAitableDateFieldType(fieldType string) bool {
return strings.EqualFold(fieldType, "date") ||
strings.EqualFold(fieldType, "createdTime") ||
strings.EqualFold(fieldType, "lastModifiedTime")
}
func isAitableDateFilterOperator(operator string) bool {
switch operator {
case "date_eq", "before", "after", "not_before", "not_after", "exist", "un_exist":
return true
default:
return false
}
}
func validateAitableMultiSelectFilterValue(value any) error {
if typed, ok := value.(string); ok && strings.TrimSpace(typed) != "" {
return nil
}
return fmt.Errorf("multipleSelect filter value must be one option-name string; the persisted view protocol does not support a multi-value OR expression")
}
func compactJSON(value any) string {
raw, err := json.Marshal(value)
if err != nil {
return fmt.Sprintf("%#v", value)
}
return string(raw)
}
func newAitableCommand() *cobra.Command {
// Product-level Agent routing Decl (migrated from selection/aitable.json
// products.aitable). Catalog assembly stamps provenance contract_final.
@@ -4076,7 +4439,7 @@ colorConfigs (JSON 数组) / officialHoliday (bool)。`,
若传对象会自动 wrap 为数组;其他非法格式拒绝。`,
Example: ` dws aitable view update filter --view-id VIEW_ID --json '[{"operator":"and","operands":[{"operator":"eq","operands":["fldX","value"]}]}]'`,
RunE: func(cmd *cobra.Command, args []string) error {
return runAitableViewUpdateArray(cmd, "filter")
return runAitableViewUpdateFilter(cmd)
},
}
DeclareLeafMetadata(viewUpdateFilterCmd, LeafSpec{
@@ -5282,7 +5645,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
}
// create_workflow is non-idempotent. Bypass the retry wrapper to
// prevent an uncertain first response from creating a duplicate.
return callMCPToolOnServer("aitable", "create_workflow", toolArgs)
return executeAitableWorkflowPublish("create_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowCreateCmd, LeafSpec{
@@ -5376,7 +5739,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
if locale, _ := cmd.Flags().GetString("locale"); strings.TrimSpace(locale) != "" {
toolArgs["locale"] = locale
}
return callAitableTool("update_workflow", toolArgs)
return executeAitableWorkflowPublish("update_workflow", toolArgs)
},
}
DeclareLeafMetadata(workflowUpdateCmd, LeafSpec{
@@ -361,3 +361,228 @@ func TestCrossPlatformCoverageAitableDeleteCancellationEdges(t *testing.T) {
_ = runAitableCoverageCommand(t, &aitableCommandCoverageCaller{}, args...)
}
}
func TestCrossPlatformCoverageAitableViewFilterValidationAndReadBack(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
oldArgs := os.Args
t.Cleanup(func() { os.Args = oldArgs })
os.Args = []string{"dws", "aitable"}
filter := `[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]`
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"},{"fieldId":"fldB","type":"text"},{"fieldId":"fldMulti","type":"multipleSelect"}]}}`
readBack := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[{"operator":"eq","operands":["fldA","x"]},{"operator":"any_of","operands":["fldMulti","A"]}]}]}}`
t.Run("flat leaf filters are written then exactly verified", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, readBack}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err != nil || len(caller.calls) != 3 || caller.calls[0].tool != "get_fields" || caller.calls[1].tool != "update_view" || caller.calls[2].tool != "get_views" {
t.Fatalf("verified view filter = err:%v calls:%#v", err, caller.calls)
}
config, _ := caller.calls[1].args["config"].(map[string]any)
if _, ok := config["filter"].([]any); !ok {
t.Fatalf("update_view filter encoding = %#v", caller.calls[1].args)
}
})
t.Run("logical groups fail closed before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"or","operands":[{"operator":"eq","operands":["fldA","x"]},{"operator":"eq","operands":["fldB","y"]}]}]`)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("logical filter group fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("unknown field is rejected before write", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"eq","operands":["missing","x"]}]`)
if err == nil || !strings.Contains(err.Error(), "unknown fieldId") || len(caller.calls) != 1 {
t.Fatalf("unknown filter field = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("eventual persisted and wrapper is retried then verified", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti","A"]}]`
stale := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[]}}]}}`
terminal := `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":{"operator":"and","operands":[{"operator":"any_of","operands":["fldMulti","A"]}]}}]}}`
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`, stale, terminal}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err != nil || len(caller.calls) != 4 || caller.calls[2].tool != "get_views" || caller.calls[3].tool != "get_views" {
t.Fatalf("eventual wrapped filter = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select operator rejects text field", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", `--json=[{"operator":"any_of","operands":["fldA",["A"]]}]`)
if err == nil || !strings.Contains(err.Error(), "requires a multipleSelect field") || len(caller.calls) != 1 {
t.Fatalf("wrong filter field type = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select array fails closed before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A","B"]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "persisted view protocol") || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("multi-select array fail-closed = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("multi-select invalid array value fails before write", func(t *testing.T) {
input := `[{"operator":"any_of","operands":["fldMulti",["A",""]]}]`
caller := &aitableTestCaller{responses: []string{fields}}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+input)
if err == nil || !strings.Contains(err.Error(), "non-empty option-name") || len(caller.calls) != 1 {
t.Fatalf("invalid multi-select array = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("date and system-time fields require date operators", func(t *testing.T) {
fieldTypes := map[string]string{"date": "date", "created": "createdTime", "modified": "lastModifiedTime"}
for fieldID := range fieldTypes {
valid := []any{map[string]any{"operator": "date_eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(valid, fieldTypes); err != nil {
t.Fatalf("date_eq for %s: %v", fieldID, err)
}
invalid := []any{map[string]any{"operator": "eq", "operands": []any{fieldID, "2026-08-18"}}}
if err := validateAitableViewFilter(invalid, fieldTypes); err == nil || !strings.Contains(err.Error(), "invalid for") {
t.Fatalf("eq for %s = %v, want date-operator error", fieldID, err)
}
}
})
t.Run("dry-run validates but performs no write or read-back", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, dryRun: true}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter, "--dry-run")
if err != nil || len(caller.calls) != 1 || caller.calls[0].tool != "get_fields" {
t.Fatalf("view filter dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("read-back mismatch is not success", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"view","viewType":"Grid","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
err := runAitableCoverageCommand(t, caller, "view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json="+filter)
if err == nil || !strings.Contains(err.Error(), "read-back mismatch") || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("mismatched filter readback = err:%v calls:%#v", err, caller.calls)
}
})
}
func TestCrossPlatformCoverageAitableViewFilterFailureAndShapeEdges(t *testing.T) {
testseam.Swap(t, &aitableViewFilterReadbackSleep, func(time.Duration) {})
testseam.Protect(t, &os.Args)
os.Args = []string{"dws", "aitable"}
fields := `{"data":{"fields":[{"fieldId":"fldA","type":"text"}]}}`
filter := `[{"operator":"eq","operands":["fldA","x"]}]`
args := []string{"view", "update", "filter", "--base-id=b", "--table-id=t", "--view-id=view", "--json=" + filter}
t.Run("update transport error", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{fields}, errors: []error{nil, context.Canceled}}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 2 {
t.Fatalf("update error = %v, calls=%#v", err, caller.calls)
}
})
t.Run("readback transport errors exhaust", func(t *testing.T) {
errs := []error{nil, nil}
for range aitableViewFilterReadbackAttempts {
errs = append(errs, context.DeadlineExceeded)
}
caller := &aitableTestCaller{responses: []string{fields, `{"success":true}`}, errors: errs}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || len(caller.calls) != 2+aitableViewFilterReadbackAttempts {
t.Fatalf("readback errors = %v, calls=%d", err, len(caller.calls))
}
})
t.Run("readback wrong identity exhausts", func(t *testing.T) {
responses := []string{fields, `{"success":true}`}
for range aitableViewFilterReadbackAttempts {
responses = append(responses, `{"data":{"views":[{"viewId":"other","filter":[]}]}}`)
}
caller := &aitableTestCaller{responses: responses}
if err := runAitableCoverageCommand(t, caller, args...); err == nil || !strings.Contains(err.Error(), "returned viewId") {
t.Fatalf("wrong readback identity = %v", err)
}
})
loadCases := []struct {
name string
response string
callErr error
wantError string
}{
{name: "transport", callErr: context.Canceled, wantError: "context canceled"},
{name: "invalid json", response: `{`, wantError: "not valid JSON"},
{name: "missing collection", response: `{}`, wantError: "missing the fields collection"},
{name: "missing identity", response: `{"fields":[{"type":"text"}]}`, wantError: "missing fieldId or type"},
}
for _, tc := range loadCases {
t.Run("load fields "+tc.name, func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{tc.response}, errors: []error{tc.callErr}}
installAitableDeps(t, caller)
if _, err := loadAitableFieldTypes(context.Background(), "b", "t"); err == nil || !strings.Contains(err.Error(), tc.wantError) {
t.Fatalf("load fields error = %v, want %q", err, tc.wantError)
}
})
}
t.Run("load legacy field keys", func(t *testing.T) {
caller := &aitableTestCaller{responses: []string{`{"fieldList":[{"id":"legacy","fieldType":"text"}]}`}}
installAitableDeps(t, caller)
got, err := loadAitableFieldTypes(context.Background(), "b", "t")
if err != nil || got["legacy"] != "text" {
t.Fatalf("legacy field keys = %#v, %v", got, err)
}
})
if _, ok := findAitableObjectList(map[string]any{"fields": "bad"}, "fields"); ok {
t.Fatal("scalar fields collection must fail")
}
if _, ok := findAitableObjectList(map[string]any{"fields": []any{"bad"}}, "fields"); ok {
t.Fatal("scalar field item must fail")
}
if got, ok := findAitableObjectList([]any{"skip", map[string]any{"nested": map[string]any{"fields": []any{map[string]any{"fieldId": "f"}}}}}, "fields"); !ok || len(got) != 1 {
t.Fatalf("recursive fields = %#v, %v", got, ok)
}
if got, ok := findAitableObjectList(map[string]any{
"fieldList": []any{map[string]any{"fieldId": "legacy"}},
"fields": []any{map[string]any{"fieldId": "canonical"}},
}, "fields", "fieldList"); !ok || len(got) != 1 || got[0]["fieldId"] != "canonical" {
t.Fatalf("declared collection priority = %#v, %v", got, ok)
}
invalidFilters := []struct {
filter []any
want string
}{
{filter: []any{"bad"}, want: "must be an object"},
{filter: []any{map[string]any{"operator": "bogus", "operands": []any{}}}, want: "unsupported operator"},
{filter: []any{map[string]any{"operator": "eq", "operands": "bad"}}, want: "requires an operands array"},
{filter: []any{map[string]any{"operator": "and", "operands": []any{}}}, want: "logical operator"},
{filter: []any{map[string]any{"operator": "exist", "operands": []any{"f", "extra"}}}, want: "requires 1 operands"},
{filter: []any{map[string]any{"operator": "eq", "operands": []any{1, "x"}}}, want: "requires a fieldId"},
{filter: []any{map[string]any{"operator": "any_of", "operands": []any{"multi", 1}}}, want: "one option-name string"},
}
for _, tc := range invalidFilters {
if err := validateAitableViewFilter(tc.filter, map[string]string{"f": "text", "multi": "multipleSelect"}); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Errorf("validate filter %#v = %v, want %q", tc.filter, err, tc.want)
}
}
if err := validateAitableMultiSelectOptionNames(nil); err == nil {
t.Fatal("empty any_of array must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"ok", 1}); err == nil {
t.Fatal("non-string any_of option must fail")
}
if err := validateAitableMultiSelectOptionNames([]any{"first", " second "}); err != nil {
t.Fatalf("valid any_of option names = %v", err)
}
if got := compactJSON(make(chan int)); !strings.HasPrefix(got, "(chan int)") {
t.Fatalf("compactJSON fallback = %q", got)
}
if persistedViewFilterMatches("bad", nil) || persistedViewFilterMatches(map[string]any{"operator": "or"}, nil) {
t.Fatal("invalid persisted wrapper must not match")
}
}
+5 -1
View File
@@ -24,6 +24,7 @@ type aitableTestCaller struct {
responses []string
errors []error
calls []aitableTestCall
dryRun bool
}
func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
@@ -38,8 +39,11 @@ func (c *aitableTestCaller) CallTool(_ context.Context, server, tool string, arg
}
return textToolResult(response), nil
}
func (c *aitableTestCaller) CallReadTool(ctx context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
return c.CallTool(ctx, server, tool, args)
}
func (*aitableTestCaller) Format() string { return "json" }
func (*aitableTestCaller) DryRun() bool { return false }
func (c *aitableTestCaller) DryRun() bool { return c.dryRun }
func (*aitableTestCaller) Fields() string { return "" }
func (*aitableTestCaller) JQ() string { return "" }
@@ -23,27 +23,42 @@ type aitableWorkflowCall struct {
}
type aitableWorkflowCaller struct {
calls []aitableWorkflowCall
calls []aitableWorkflowCall
response string
err error
dryRun bool
}
func (c *aitableWorkflowCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
c.calls = append(c.calls, aitableWorkflowCall{productID: productID, toolName: toolName, args: args})
if c.err != nil {
return nil, c.err
}
response := c.response
if response == "" {
response = `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`
}
return &edition.ToolResult{Content: []edition.ContentBlock{{
Type: "text",
Text: `{"status":"success","data":{"valid":true,"flowId":"flow-test","issues":[]}}`,
Text: response,
}}}, nil
}
func (*aitableWorkflowCaller) Format() string { return "json" }
func (*aitableWorkflowCaller) DryRun() bool { return false }
func (c *aitableWorkflowCaller) DryRun() bool { return c.dryRun }
func (*aitableWorkflowCaller) Fields() string { return "" }
func (*aitableWorkflowCaller) JQ() string { return "" }
func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*aitableWorkflowCaller, error) {
t.Helper()
caller := &aitableWorkflowCaller{}
return caller, runAitableWorkflowCommandWithCaller(t, caller, stdin, args...)
}
func runAitableWorkflowCommandWithCaller(t *testing.T, caller *aitableWorkflowCaller, stdin io.Reader, args ...string) error {
t.Helper()
testseam.Protect(t, &os.Args)
caller := &aitableWorkflowCaller{}
InitDepsForTest(t, caller)
deps.Out.w = io.Discard
os.Args = append([]string{"dws", "aitable", "workflow"}, args...)
@@ -51,6 +66,7 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
cmd := newAitableCommand()
cmd.PersistentFlags().String("format", "json", "output format")
cmd.PersistentFlags().Bool("yes", false, "skip confirmation")
cmd.PersistentFlags().Bool("dry-run", false, "preview only")
cmd.SilenceErrors = true
cmd.SilenceUsage = true
cmd.SetArgs(append([]string{"workflow"}, args...))
@@ -58,10 +74,10 @@ func runAitableWorkflowCommand(t *testing.T, stdin io.Reader, args ...string) (*
stdin = strings.NewReader("")
}
cmd.SetIn(stdin)
return caller, cmd.Execute()
return cmd.Execute()
}
func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
func TestCrossPlatformCoverageAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
wantDSL := map[string]any{
"version": "workflow-dsl/v1",
"name": "create test",
@@ -92,6 +108,65 @@ func TestAitableWorkflowCreateMapsDSLWithoutRetry(t *testing.T) {
}
}
func TestCrossPlatformCoverageAitableWorkflowPublishRejectsFalseSuccess(t *testing.T) {
tests := []struct {
name string
response string
want string
}{
{name: "valid false", response: `{"status":"success","data":{"valid":false,"issues":[{"message":"bad dsl"}]}}`, want: "valid=false"},
{name: "missing valid", response: `{"status":"success","data":{"flowId":"flow-test"}}`, want: "missing valid"},
{name: "missing flow id", response: `{"status":"success","data":{"valid":true}}`, want: "missing a non-empty flowId"},
{name: "wrong valid type", response: `{"status":"success","data":{"valid":"true","flowId":"flow-test"}}`, want: "valid must be boolean"},
{name: "malformed response", response: `{`, want: "not a JSON object"},
{name: "null response", response: `null`, want: "not a JSON object"},
{name: "conflicting valid", response: `{"valid":true,"data":{"valid":false,"flowId":"flow-test"}}`, want: "conflicting valid values"},
{name: "invalid flow id", response: `{"valid":true,"flowId":1}`, want: "flowId must be a non-empty string"},
{name: "conflicting workflow ids", response: `{"valid":true,"flowId":"one","data":{"workflowId":"two"}}`, want: "conflicting workflow IDs"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &aitableWorkflowCaller{response: tc.response}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), tc.want) || len(caller.calls) != 1 {
t.Fatalf("workflow false success = err:%v calls:%#v", err, caller.calls)
}
})
}
t.Run("update uses the same strict contract", func(t *testing.T) {
caller := &aitableWorkflowCaller{response: `{"status":"success","data":{"valid":false}}`}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"update", "--base-id", "base", "--workflow-id", "flow", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), "valid=false") || len(caller.calls) != 1 || caller.calls[0].toolName != "update_workflow" {
t.Fatalf("workflow update false success = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("dry-run does not call publish tool", func(t *testing.T) {
caller := &aitableWorkflowCaller{dryRun: true}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`, "--dry-run")
if err != nil || len(caller.calls) != 0 {
t.Fatalf("workflow create dry-run = err:%v calls:%#v", err, caller.calls)
}
})
t.Run("transport error", func(t *testing.T) {
caller := &aitableWorkflowCaller{err: context.Canceled}
err := runAitableWorkflowCommandWithCaller(t, caller, nil,
"create", "--base-id", "base", "--dsl", `{"version":"workflow-dsl/v1","name":"test"}`)
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || len(caller.calls) != 1 {
t.Fatalf("workflow transport error = err:%v calls:%#v", err, caller.calls)
}
})
if _, _, _, err := strictAitableWorkflowPublishResult(nil); err == nil || !strings.Contains(err.Error(), "empty response") {
t.Fatalf("nil workflow envelope = %v", err)
}
}
func TestAitableWorkflowEditExampleMapsEmptyArguments(t *testing.T) {
caller, err := runAitableWorkflowCommand(t, nil, "edit-example")
if err != nil {
+50 -9
View File
@@ -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 <openConversationId> --user <userId> --role-ids roleId1,roleId2
Example: ` dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>
# 查询人员: dws contact user search --keyword "姓名" --format json
# 查询 role-id: dws chat group-role list --conversation-id <openConversationId>`,
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 <openConversationId> --user <userId> --role-ids roleId1,roleId2"},
Examples: []string{"dws chat group-role set-user --conversation-id <openConversationId> --user <userId> --role-id <openRoleId>"},
},
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
+151 -2
View File
@@ -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"},
+253
View File
@@ -0,0 +1,253 @@
package helpers
import (
"fmt"
"strings"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
"github.com/spf13/cobra"
)
const personalEmotionUnpinnedReason = "Reviewed unpinned remote adapter: this executable CLI wrapper calls a remote helper that is absent from the pinned MCP metadata snapshot; no single pinned semantically equivalent interface_ref can represent the command."
func newChatEmotionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "emotion",
Short: "个人收藏表情",
Long: "查询、发送和新增当前用户的个人收藏表情。",
RunE: groupRunE,
}
cmd.AddCommand(
newChatEmotionListCommand(),
newChatEmotionSendCommand(),
newChatEmotionFavoriteCommand(),
)
return cmd
}
func newChatEmotionListCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "list",
Short: "列出个人收藏表情",
RunE: func(cmd *cobra.Command, args []string) error {
return callMCPToolOnServer("im", "list_personal_emotions", map[string]any{})
},
}
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "read", Risk: "low", Confirmation: "not_required", Idempotency: "idempotent",
},
Contract: LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "list_personal_emotions",
CanonicalPath: "chat.list_personal_emotions", CLIPath: "chat emotion list", PrimaryCLIPath: "chat emotion list",
},
Description: "列出当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "列出当前用户的个人收藏表情",
UseWhen: []string{"需要查看当前用户已收藏的表情、获取 emotionId 或 mediaId 时"},
AvoidWhen: []string{"查询消息 reaction 使用 chat message list-emotion-replies"},
Examples: []string{"dws chat emotion list --format json"},
},
},
})
return cmd
}
func newChatEmotionSendCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "send",
Short: "发送个人收藏表情",
Long: `发送当前用户的个人收藏表情。
⚠️ 重要:该接口会真实发送表情到目标会话,不可用于测试或试探性调用。调用前必须确认表情媒体 ID 和接收对象无误。`,
Example: ` dws chat emotion send --media-id <mediaId> --group <openConversationId>
dws chat emotion send --media-id <mediaId> --emotion-id <emotionId> --user <userId>
dws chat emotion send --media-id <mediaId> --open-dingtalk-id <openDingTalkId> --uuid <idempotencyKey>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
target, err := personalEmotionSendTarget(cmd)
if err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
emotionID, _ := cmd.Flags().GetString("emotion-id")
if strings.TrimSpace(emotionID) != "" {
payload["emotionId"] = strings.TrimSpace(emotionID)
}
for key, value := range target {
payload[key] = value
}
if uuid := strings.TrimSpace(flagOrFallback(cmd, "uuid", "idempotency-key")); uuid != "" {
payload["uuid"] = uuid
}
return callMCPToolOnServer("im", "send_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "表情媒体 ID (必填)")
cmd.Flags().String("emotion-id", "", "表情 ID")
cmd.Flags().String("conversation-id", "", "群聊 openConversationId")
cmd.Flags().String("group", "", "群聊 openConversationId(--conversation-id 别名)")
cmd.Flags().String("user", "", "单聊接收人 userId;CLI 会解析为 openDingTalkId")
cmd.Flags().String("open-dingtalk-id", "", "单聊接收人 openDingTalkId")
cmd.Flags().String("uuid", "", "幂等键")
cmd.Flags().String("idempotency-key", "", "幂等键(--uuid 别名)")
cmd.MarkFlagsMutuallyExclusive("conversation-id", "group")
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
MutuallyExclusive: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
RequireOneOf: [][]string{{"conversation-id", "group", "user", "open-dingtalk-id"}},
})
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionSendContract(),
})
return cmd
}
func newChatEmotionFavoriteCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "favorite",
Short: "新增个人收藏表情",
Example: ` dws chat emotion favorite --media-id <mediaId> --name "赞"
dws chat emotion favorite --media-id <mediaId> --source-conversation-id <cid> --source-message-id <mid>`,
RunE: func(cmd *cobra.Command, args []string) error {
mediaID, _ := cmd.Flags().GetString("media-id")
if strings.TrimSpace(mediaID) == "" {
return fmt.Errorf("--media-id is required")
}
sourceConversationID, _ := cmd.Flags().GetString("source-conversation-id")
sourceMessageID, _ := cmd.Flags().GetString("source-message-id")
if err := validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID); err != nil {
return err
}
payload := map[string]any{"mediaId": strings.TrimSpace(mediaID)}
name, _ := cmd.Flags().GetString("name")
if strings.TrimSpace(name) != "" {
payload["name"] = strings.TrimSpace(name)
}
if strings.TrimSpace(sourceConversationID) != "" {
payload["sourceConversationId"] = strings.TrimSpace(sourceConversationID)
payload["sourceMessageId"] = strings.TrimSpace(sourceMessageID)
}
return callMCPToolOnServer("im", "favorite_personal_emotion", payload)
},
}
cmd.Flags().String("media-id", "", "待收藏 mediaId (必填)")
cmd.Flags().String("name", "", "表情名称")
cmd.Flags().String("source-conversation-id", "", "来源会话 ID;需与 --source-message-id 成对指定")
cmd.Flags().String("source-message-id", "", "来源消息 ID;需与 --source-conversation-id 成对指定")
DeclareLeafMetadata(cmd, LeafSpec{
Safety: contract.SafetySpec{
Effect: "write", Risk: "medium", Confirmation: "not_required", Idempotency: "unknown",
},
Contract: personalEmotionFavoriteContract(),
})
return cmd
}
func personalEmotionSendTarget(cmd *cobra.Command) (map[string]any, error) {
groupID := strings.TrimSpace(flagOrFallback(cmd, "conversation-id", "group"))
userID, _ := cmd.Flags().GetString("user")
openDingTalkID, _ := cmd.Flags().GetString("open-dingtalk-id")
userID = strings.TrimSpace(userID)
openDingTalkID = strings.TrimSpace(openDingTalkID)
specified := 0
for _, value := range []string{groupID, userID, openDingTalkID} {
if value != "" {
specified++
}
}
if specified != 1 {
return nil, fmt.Errorf("--conversation-id, --user or --open-dingtalk-id is required; specify exactly one")
}
if groupID != "" {
return map[string]any{"openConversationId": groupID}, nil
}
if openDingTalkID != "" {
if err := targetresolver.ValidateExplicitOpenDingTalkID("--open-dingtalk-id", openDingTalkID); err != nil {
return nil, err
}
return map[string]any{"receiverOpenDingTalkId": openDingTalkID}, nil
}
if isOpenDingTalkID(userID) {
return map[string]any{"receiverOpenDingTalkId": userID}, nil
}
resolved, err := resolveOpenDingTalkID(cmd.Context(), userID)
if err != nil {
return nil, fmt.Errorf("cannot resolve --user %q to openDingTalkId: %w; pass --open-dingtalk-id instead", userID, err)
}
return map[string]any{"receiverOpenDingTalkId": resolved}, nil
}
func validatePersonalEmotionSourcePair(sourceConversationID, sourceMessageID string) error {
hasConversation := strings.TrimSpace(sourceConversationID) != ""
hasMessage := strings.TrimSpace(sourceMessageID) != ""
if hasConversation != hasMessage {
return fmt.Errorf("--source-conversation-id and --source-message-id must be specified together")
}
return nil
}
func personalEmotionSendContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "send_personal_emotion",
CanonicalPath: "chat.send_personal_emotion", CLIPath: "chat emotion send", PrimaryCLIPath: "chat emotion send",
},
Description: "以当前用户身份向群聊或单聊发送个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "以当前用户身份发送个人收藏表情",
UseWhen: []string{"用户明确要求发送个人收藏表情,且已提供 mediaId 或 emotionId 时"},
AvoidWhen: []string{"发送普通文本、Markdown 或文件时使用 chat message send"},
Examples: []string{"dws chat emotion send --media-id <mediaId> --group <openConversationId> --uuid <idempotencyKey>"},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "emotion-id", Property: "emotionId", Required: boolPtr(false)},
{Name: "conversation-id", Property: "openConversationId", Required: boolPtr(false)},
{Name: "group", Property: "openConversationId", Required: boolPtr(false)},
{Name: "user", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "open-dingtalk-id", Property: "receiverOpenDingTalkId", Required: boolPtr(false)},
{Name: "uuid", Property: "uuid", Required: boolPtr(false)},
{Name: "idempotency-key", Property: "uuid", Required: boolPtr(false)},
},
}
}
func personalEmotionFavoriteContract() LeafContract {
return LeafContract{
Identity: contract.ToolIdentitySpec{
ProductID: "chat", Name: "favorite_personal_emotion",
CanonicalPath: "chat.favorite_personal_emotion", CLIPath: "chat emotion favorite", PrimaryCLIPath: "chat emotion favorite",
},
Description: "将 mediaId 新增到当前用户的个人收藏表情",
Interface: &contract.InterfaceSpec{
Mode: "composite", Availability: "available", Reason: personalEmotionUnpinnedReason,
},
Selection: contract.SelectionSpec{
AgentSummary: "新增当前用户的个人收藏表情",
UseWhen: []string{"用户要把一个 mediaId 收藏为个人表情时"},
AvoidWhen: []string{"收藏消息使用 chat message add-favorite"},
Examples: []string{"dws chat emotion favorite --media-id <mediaId> --name \"赞\""},
},
Parameters: []contract.ParamDecl{
{Name: "media-id", Property: "mediaId", Required: boolPtr(true)},
{Name: "name", Property: "name", Required: boolPtr(false)},
{Name: "source-conversation-id", Property: "sourceConversationId", Required: boolPtr(false), RequiredWhen: "source-message-id is provided"},
{Name: "source-message-id", Property: "sourceMessageId", Required: boolPtr(false), RequiredWhen: "source-conversation-id is provided"},
},
}
}
@@ -0,0 +1,254 @@
package helpers
import (
"context"
"io"
"reflect"
"strings"
"testing"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
)
type personalEmotionCall struct {
server string
tool string
args map[string]any
}
type personalEmotionCaller struct {
calls []personalEmotionCall
}
func (c *personalEmotionCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
copied := make(map[string]any, len(args))
for key, value := range args {
copied[key] = value
}
c.calls = append(c.calls, personalEmotionCall{server: server, tool: tool, args: copied})
if server == "contact" && tool == "get_user_info_by_user_ids" {
return textToolResult(`{"result":[{"userId":"u1","openDingTalkId":"` + helperCurrentDOpenID2 + `"}]}`), nil
}
return textToolResult(`{"ok":true}`), nil
}
func (*personalEmotionCaller) Format() string { return "json" }
func (*personalEmotionCaller) DryRun() bool { return false }
func (*personalEmotionCaller) Fields() string { return "" }
func (*personalEmotionCaller) JQ() string { return "" }
func executePersonalEmotionCommand(t *testing.T, caller *personalEmotionCaller, args ...string) error {
t.Helper()
installHelpersCoreDeps(t, caller)
deps.Out.w = io.Discard
deps.Out.errW = io.Discard
root := newChatCommand()
root.SilenceErrors = true
root.SilenceUsage = true
root.SetOut(io.Discard)
root.SetErr(io.Discard)
root.SetArgs(args)
return root.ExecuteContext(context.Background())
}
func requirePersonalEmotionCall(t *testing.T, caller *personalEmotionCaller, tool string, want map[string]any) {
t.Helper()
if len(caller.calls) != 1 {
t.Fatalf("calls = %d, want 1: %+v", len(caller.calls), caller.calls)
}
call := caller.calls[0]
if call.server != "im" || call.tool != tool {
t.Fatalf("tool call = %s/%s, want im/%s", call.server, call.tool, tool)
}
if !reflect.DeepEqual(call.args, want) {
t.Fatalf("args = %#v, want %#v", call.args, want)
}
}
func TestChatEmotionListCallsIMToolWithoutBusinessArgs(t *testing.T) {
// TC-001: list 无业务参数,当前用户身份由 MCP server 注入。
caller := &personalEmotionCaller{}
if err := executePersonalEmotionCommand(t, caller, "emotion", "list"); err != nil {
t.Fatalf("chat emotion list returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "list_personal_emotions", map[string]any{})
}
func TestChatEmotionSendMapsGroupTargetAndIdempotency(t *testing.T) {
// TC-002: 群聊目标映射为 openConversationId,uuid 与表情字段按 MCP 字段透传。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--emotion-id", "emotion123",
"--group", "cid123",
"--idempotency-key", "idem-001",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"emotionId": "emotion123",
"openConversationId": "cid123",
"uuid": "idem-001",
})
}
func TestChatEmotionSendMapsOpenDingTalkTarget(t *testing.T) {
// TC-003: 已知 openDingTalkId 时直传 receiverOpenDingTalkId,不做外部解析。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--open-dingtalk-id", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendTreatsOpenDingTalkIDPassedAsUserAsResolvedTarget(t *testing.T) {
// TC-004: --user 收到 openDingTalkId 形态时保持 chat message send 的兼容语义。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", helperCurrentDOpenID,
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "send_personal_emotion", map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID,
})
}
func TestChatEmotionSendResolvesUserIDTarget(t *testing.T) {
// TC-004b: --user 收到普通 userId 时先解析为 openDingTalkId,再发送个人收藏表情。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "send",
"--media-id", "@media",
"--user", "u1",
)
if err != nil {
t.Fatalf("chat emotion send returned error: %v", err)
}
if len(caller.calls) != 2 {
t.Fatalf("calls = %d, want contact resolve then send: %+v", len(caller.calls), caller.calls)
}
resolveCall := caller.calls[0]
if resolveCall.server != "contact" || resolveCall.tool != "get_user_info_by_user_ids" {
t.Fatalf("resolve call = %s/%s, want contact/get_user_info_by_user_ids", resolveCall.server, resolveCall.tool)
}
sendCall := caller.calls[1]
if sendCall.server != "im" || sendCall.tool != "send_personal_emotion" {
t.Fatalf("send call = %s/%s, want im/send_personal_emotion", sendCall.server, sendCall.tool)
}
want := map[string]any{
"mediaId": "@media",
"receiverOpenDingTalkId": helperCurrentDOpenID2,
}
if !reflect.DeepEqual(sendCall.args, want) {
t.Fatalf("send args = %#v, want %#v", sendCall.args, want)
}
}
func TestChatEmotionSendRejectsInvalidTargets(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing target",
args: []string{"emotion", "send", "--media-id", "@media"},
wantErr: "specify exactly one",
},
{
name: "multiple targets",
args: []string{"emotion", "send", "--media-id", "@media", "--group", "cid", "--open-dingtalk-id", helperCurrentDOpenID},
wantErr: "specify exactly one",
},
{
name: "missing media",
args: []string{"emotion", "send", "--group", "cid"},
wantErr: "--media-id is required",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
func TestChatEmotionFavoriteMapsOptionalSourcePair(t *testing.T) {
// TC-005: 收藏来源字段成对出现时透传为 sourceConversationId/sourceMessageId。
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller,
"emotion", "favorite",
"--media-id", "@media",
"--name", "赞",
"--source-conversation-id", "cid123",
"--source-message-id", "msg123",
)
if err != nil {
t.Fatalf("chat emotion favorite returned error: %v", err)
}
requirePersonalEmotionCall(t, caller, "favorite_personal_emotion", map[string]any{
"mediaId": "@media",
"name": "赞",
"sourceConversationId": "cid123",
"sourceMessageId": "msg123",
})
}
func TestChatEmotionFavoriteRejectsMissingRequiredOrUnpairedSource(t *testing.T) {
tests := []struct {
name string
args []string
wantErr string
}{
{
name: "missing media",
args: []string{"emotion", "favorite", "--name", "赞"},
wantErr: "--media-id is required",
},
{
name: "source conversation only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-conversation-id", "cid123"},
wantErr: "must be specified together",
},
{
name: "source message only",
args: []string{"emotion", "favorite", "--media-id", "@media", "--source-message-id", "msg123"},
wantErr: "must be specified together",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
caller := &personalEmotionCaller{}
err := executePersonalEmotionCommand(t, caller, tc.args...)
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
t.Fatalf("error = %v, want containing %q", err, tc.wantErr)
}
if len(caller.calls) != 0 {
t.Fatalf("invalid command reached MCP: %+v", caller.calls)
}
})
}
}
+20 -2
View File
@@ -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
@@ -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)
}
}
+2 -2
View File
@@ -108,7 +108,7 @@ func driveFolderPushResultSpec() *contract.ResultSpec {
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令"},
"if_exists":{"type":"string","description":"远端同名文件处理策略"},
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数","properties":{"uploaded":{"type":"integer","description":"计划上传或覆盖的文件数"},"skipped":{"type":"integer","description":"计划跳过的文件数"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的推送计划","properties":{"summary":{"type":"object","description":"计划动作计数;兼容执行计数 uploaded/skipped 在预览中保持为零","properties":{"uploaded":{"type":"integer","description":"兼容字段;预览未执行上传,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"aborted":{"type":"boolean","description":"计划是否会中止"},"planned_uploads":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["uploaded","skipped","failed","aborted"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作或已确认的预检失败"},"size_bytes":{"type":"integer","description":"文件字节数"},"error":{"type":"string","description":"预检失败原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["summary","items"],"additionalProperties":false}
},
"additionalProperties":false
}`),
@@ -134,7 +134,7 @@ func driveFolderSyncResultSpec() *contract.ResultSpec {
"executed":{"type":"boolean","description":"是否执行了写操作","const":false},
"preview_kind":{"type":"string","description":"预览类型","enum":["plan"]},
"operation":{"type":"string","description":"预览的命令","const":"drive sync"},
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数","properties":{"pulled":{"type":"integer","description":"计划拉取的条目数"},"pushed":{"type":"integer","description":"计划推送的条目数"},"skipped":{"type":"integer","description":"计划跳过的条目数"},"failed":{"type":"integer","description":"预检失败的条目数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"计划动作"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
"plan":{"type":"object","description":"预览的同步计划","properties":{"detection":{"type":"string","description":"计划使用的差异检测模式","enum":["exact","quick"]},"diff":{"type":"object","description":"计划执行前的双端差异","properties":{"new_local":{"type":"array","description":"仅本地存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"new_remote":{"type":"array","description":"仅钉盘存在的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"modified":{"type":"array","description":"双端都存在但内容或时间不同的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unchanged":{"type":"array","description":"双端一致的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}},"unknown":{"type":"array","description":"exact 模式下因缺少可靠远端哈希而无法判定的文件","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件路径"}},"required":["rel_path"],"additionalProperties":false}}},"required":["new_local","new_remote","modified","unchanged","unknown"],"additionalProperties":false},"summary":{"type":"object","description":"计划动作计数;兼容执行计数 pulled/pushed/skipped 在预览中保持为零","properties":{"pulled":{"type":"integer","description":"兼容字段;预览未执行拉取,固定为零"},"pushed":{"type":"integer","description":"兼容字段;预览未执行推送,固定为零"},"skipped":{"type":"integer","description":"兼容字段;预览未执行跳过,固定为零"},"failed":{"type":"integer","description":"预检失败的条目数"},"planned_pulls":{"type":"integer","description":"计划拉取的文件数"},"planned_pushes":{"type":"integer","description":"计划上传或覆盖的文件数"},"planned_skips":{"type":"integer","description":"计划按策略跳过的文件数"},"planned_folders":{"type":"integer","description":"计划创建的远端目录数"}},"required":["pulled","pushed","skipped","failed"],"additionalProperties":false},"items":{"type":"array","description":"计划中的逐条目动作","items":{"type":"object","properties":{"rel_path":{"type":"string","description":"相对文件或目录路径"},"action":{"type":"string","description":"未执行的 planned_* 计划动作、待决策动作或预检失败"},"direction":{"type":"string","description":"同步方向"},"error":{"type":"string","description":"失败或跳过原因"}},"required":["rel_path","action"],"additionalProperties":false}}},"required":["detection","diff","summary","items"],"additionalProperties":false}
},
"oneOf":[
{"required":["detection","diff","summary","items"]},
@@ -93,10 +93,26 @@ func TestCrossPlatformCoverageDriveFolderContractsPublishResultAndDryRun(t *test
if name == "sync" {
assertDriveSyncResultSchema(t, final.Result.DataSchema)
}
if name == "push" {
assertDrivePushResultSchema(t, final.Result.DataSchema)
}
})
}
}
func assertDrivePushResultSchema(t *testing.T, raw json.RawMessage) {
t.Helper()
properties := resultSchemaProperties(t, raw)
plan := schemaObjectProperty(t, properties, "plan")
summary := schemaObjectProperty(t, schemaProperties(t, plan), "summary")
summaryProperties := schemaProperties(t, summary)
for _, name := range []string{"planned_uploads", "planned_skips", "planned_folders"} {
if _, ok := summaryProperties[name]; !ok {
t.Fatalf("push plan summary schema is missing %s", name)
}
}
}
func resultSchemaProperties(t *testing.T, raw json.RawMessage) map[string]any {
t.Helper()
var schema map[string]any
@@ -133,6 +149,13 @@ func assertDriveSyncResultSchema(t *testing.T, raw json.RawMessage) {
planProperties := schemaProperties(t, plan)
planDiff := schemaObjectProperty(t, planProperties, "diff")
assertSchemaRequired(t, planDiff, "new_local", "new_remote", "modified", "unchanged", "unknown")
planSummary := schemaObjectProperty(t, planProperties, "summary")
planSummaryProperties := schemaProperties(t, planSummary)
for _, name := range []string{"planned_pulls", "planned_pushes", "planned_skips", "planned_folders"} {
if _, ok := planSummaryProperties[name]; !ok {
t.Fatalf("sync plan summary schema is missing %s", name)
}
}
var schema map[string]any
if err := json.Unmarshal(raw, &schema); err != nil {
+26 -14
View File
@@ -39,6 +39,11 @@ const (
pushActionSkipped = "skipped" // 按 --if-exists 跳过
pushActionFolderCreated = "folder_created" // 新建远端目录(不计入 uploaded)
pushActionFailed = "failed"
pushActionPlannedUpload = "planned_upload"
pushActionPlannedOverwrite = "planned_overwrite"
pushActionPlannedSkip = "planned_skip"
pushActionPlannedFolderCreate = "planned_folder_create"
)
// localPushFile 描述一个待推送的本地常规文件。
@@ -79,10 +84,13 @@ type drivePushItem struct {
// drivePushSummary 是各动作的计数汇总。uploaded 同时统计新建与覆盖。
type drivePushSummary struct {
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
Uploaded int `json:"uploaded"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Aborted bool `json:"aborted"`
PlannedUploads int `json:"planned_uploads,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
type drivePushResult struct {
@@ -380,7 +388,8 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionFolderCreated})
plan.Summary.PlannedFolders++
plan.Items = append(plan.Items, drivePushItem{RelPath: dir, Action: pushActionPlannedFolderCreate})
}
for _, lf := range localFiles {
size := lf.Size
@@ -390,25 +399,25 @@ func printDrivePushDryRunWithPreflight(ifExists string, remoteFiles map[string]*
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: pushActionFailed, SizeBytes: &size, Error: "父目录未能创建"})
continue
}
action := pushActionUploaded
action := pushActionPlannedUpload
if rf, exists := remoteFiles[lf.RelPath]; exists {
switch ifExists {
case ifExistsSkip:
action = pushActionSkipped
action = pushActionPlannedSkip
case ifExistsSmart:
if rf.ModifiedTimeValid && rf.ModifiedTime >= lf.ModTimeMillis {
action = pushActionSkipped
action = pushActionPlannedSkip
} else {
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
case ifExistsOverwrite:
action = pushActionOverwritten
action = pushActionPlannedOverwrite
}
}
if action == pushActionSkipped {
plan.Summary.Skipped++
if action == pushActionPlannedSkip {
plan.Summary.PlannedSkips++
} else {
plan.Summary.Uploaded++
plan.Summary.PlannedUploads++
}
plan.Items = append(plan.Items, drivePushItem{RelPath: lf.RelPath, Action: action, SizeBytes: &size})
}
@@ -811,7 +820,10 @@ func defaultPushPutOpenedFile(ctx context.Context, url string, headers map[strin
if _, err := file.Seek(0, io.SeekStart); err != nil {
return fmt.Errorf("failed to seek upload file: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, file)
// net/http owns and closes Request.Body after RoundTrip. The *os.File belongs
// to pushUploadFilePinned, which must stat it after PUT before commit_upload;
// wrap it in a no-op closer so HTTP completion cannot close that shared handle.
req, err := http.NewRequestWithContext(ctx, http.MethodPut, url, io.NopCloser(file))
if err != nil {
return fmt.Errorf("failed to create upload request: %w", err)
}
@@ -421,6 +421,29 @@ func TestCrossPlatformCoverageDrivePushFinalDefaultOpenedPUTAndCommit(t *testing
}
}
func TestCrossPlatformCoverageDrivePushOpenedPUTKeepsCallerOwnedHandle(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, request *http.Request) {
_, _ = io.Copy(io.Discard, request.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
path := filepath.Join(t.TempDir(), "payload")
mustWrite(t, path, "payload")
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
if err := defaultPushPutOpenedFile(context.Background(), server.URL, nil, file, 7); err != nil {
t.Fatalf("PUT failed: %v", err)
}
if _, err := file.Stat(); err != nil {
t.Fatalf("HTTP PUT closed its caller-owned file handle: %v", err)
}
}
func TestCrossPlatformCoverageDriveSyncFinalPreflightAndKeepBothErrors(t *testing.T) {
t.Run("local target preflight lstat error and directory", func(t *testing.T) {
_, root, _ := openDriveFinalCoverageRoot(t)
+120 -4
View File
@@ -65,6 +65,14 @@ const (
syncActionRenamedLocal = "renamed_local"
syncActionSkipped = "skipped"
syncActionFailed = "failed"
syncActionPlannedDownload = "planned_download"
syncActionPlannedUpload = "planned_upload"
syncActionPlannedOverwrite = "planned_overwrite"
syncActionPlannedFolderCreate = "planned_folder_create"
syncActionPlannedRenameLocal = "planned_rename_local"
syncActionPlannedSkip = "planned_skip"
syncActionDecisionRequired = "decision_required"
)
// driveSyncItem 是输出 items[] 中每条操作的明细。
@@ -77,10 +85,14 @@ type driveSyncItem struct {
// driveSyncSummary 是各方向的计数汇总。
type driveSyncSummary struct {
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
Pulled int `json:"pulled"`
Pushed int `json:"pushed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
PlannedPulls int `json:"planned_pulls,omitempty"`
PlannedPushes int `json:"planned_pushes,omitempty"`
PlannedSkips int `json:"planned_skips,omitempty"`
PlannedFolders int `json:"planned_folders,omitempty"`
}
// driveSyncDiff 是本次同步前算出的五类差异(与 status 同源)。
@@ -267,6 +279,12 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
// --dry-run:只算差异、不执行任何同步动作。
if deps.Caller.DryRun() {
if len(preflight) == 0 && res.Summary.Failed == 0 {
appendDriveSyncDryRunPlan(&res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localByRel, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: newLocal, NewRemote: newRemote, Modified: modified, Unknown: unknown, OnConflict: onConflict,
})
}
return deps.Out.PrintJSON(driveSyncDryRunResult{
DryRun: true, Executed: false, PreviewKind: "plan", Operation: "drive sync", Plan: res,
})
@@ -394,6 +412,104 @@ func runDriveSync(cmd *cobra.Command, _ []string) error {
return nil
}
type driveSyncDryRunPlanInput struct {
LocalDirs []string
LocalByRel map[string]localPushFile
RemoteFiles map[string]*remoteFile
RemoteFolders map[string]string
NewLocal []string
NewRemote []string
Modified []string
Unknown []string
OnConflict string
}
func appendDriveSyncDryRunPlan(res *driveSyncResult, input driveSyncDryRunPlanInput) {
for _, rel := range input.Unknown {
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict,
Error: "远端无可靠 md5,内容无法核对,执行时会跳过(可改用 --quick 按 modified_time 比对)",
})
}
plannedFolders := make(map[string]string, len(input.RemoteFolders)+len(input.LocalDirs))
for rel, fileID := range input.RemoteFolders {
plannedFolders[rel] = fileID
}
for _, dir := range input.LocalDirs {
if _, ok := plannedFolders[dir]; ok {
continue
}
parentRel, _ := splitRel(dir)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
plannedFolders[dir] = "dry-run-planned-folder"
res.Summary.PlannedFolders++
res.Items = append(res.Items, driveSyncItem{RelPath: dir, Action: syncActionPlannedFolderCreate, Direction: syncDirectionPush})
}
for _, rel := range input.NewRemote {
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
}
for _, rel := range input.NewLocal {
parentRel, _ := splitRel(rel)
if plannedFolders[parentRel] == "" {
res.Summary.Failed++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionFailed, Direction: syncDirectionPush, Error: "父目录未能创建"})
continue
}
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedUpload, Direction: syncDirectionPush})
}
occupied := make(map[string]bool, len(input.LocalByRel)+len(input.LocalDirs)+len(input.RemoteFiles)+len(input.RemoteFolders))
for rel := range input.LocalByRel {
occupied[rel] = true
}
for _, rel := range input.LocalDirs {
occupied[rel] = true
}
for rel := range input.RemoteFiles {
occupied[rel] = true
}
for rel := range input.RemoteFolders {
if rel != "" {
occupied[rel] = true
}
}
for _, rel := range input.Modified {
switch input.OnConflict {
case syncConflictRemoteWins:
res.Summary.PlannedPulls++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull})
case syncConflictLocalWins:
res.Summary.PlannedPushes++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedOverwrite, Direction: syncDirectionConflict})
case syncConflictKeepBoth:
candidate := driveSyncSuffixedRel(rel, input.RemoteFiles[rel].FileID, occupied)
occupied[candidate] = true
res.Summary.PlannedPulls++
res.Items = append(res.Items,
driveSyncItem{RelPath: candidate, Action: syncActionPlannedRenameLocal, Direction: syncDirectionConflict},
driveSyncItem{RelPath: rel, Action: syncActionPlannedDownload, Direction: syncDirectionPull},
)
case syncConflictAsk:
res.Items = append(res.Items, driveSyncItem{
RelPath: rel, Action: syncActionDecisionRequired, Direction: syncDirectionConflict,
Error: "执行时需要交互选择 remote-wins、local-wins、keep-both 或 skip",
})
default:
res.Summary.PlannedSkips++
res.Items = append(res.Items, driveSyncItem{RelPath: rel, Action: syncActionPlannedSkip, Direction: syncDirectionConflict})
}
}
}
// syncPathBlockedByTypeConflict 判断 rel 本身或任一祖先是否是文件/目录类型冲突根。
// 祖先检查保证冲突目录下的本地、远端后代不会绕过根路径的 fail-closed 结论。
func syncPathBlockedByTypeConflict(rel string, conflicts map[string]string) bool {
@@ -3,6 +3,7 @@ package helpers
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
@@ -189,6 +190,116 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlansWithoutRemoteWrites(t *testin
}
}
func TestCrossPlatformCoverageDrivePushDryRunPublishesPlannedNotExecutedActions(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
caller := syncCaller(nil)
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "push"})
cmd := findDriveSubcommand(t, "push")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run push: %v", err)
}
var payload map[string]any
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run push: %v\n%s", err, out.String())
}
plan := payload["plan"].(map[string]any)
summary := plan["summary"].(map[string]any)
if summary["uploaded"] != float64(0) || summary["planned_uploads"] != float64(1) {
t.Fatalf("dry-run summary reports execution instead of a plan: %#v", summary)
}
items := plan["items"].([]any)
if got := items[0].(map[string]any)["action"]; got != "planned_upload" {
t.Fatalf("dry-run action = %v, want planned_upload", got)
}
}
func TestCrossPlatformCoverageDriveSyncDryRunReturnsRealActionPlan(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, "local.txt"), "local")
mustWrite(t, filepath.Join(dir, "shared.txt"), "shared")
if err := os.Mkdir(filepath.Join(dir, "empty-folder"), 0o755); err != nil {
t.Fatal(err)
}
caller := syncCaller(map[string]string{
"ROOT": `{"result":{"items":[{"name":"remote.txt","type":"file","fileId":"R","modifyTime":2},{"name":"shared.txt","type":"file","fileId":"S","modifyTime":2}],"nextToken":""}}`,
})
caller.dryRun = true
var out bytes.Buffer
testseam.Swap(t, &deps, &Deps{Caller: caller, Out: &Formatter{w: &out}})
testseam.Swap(t, &os.Args, []string{"dws", "drive", "sync"})
cmd := findDriveSubcommand(t, "sync")
mustSetFlags(t, cmd, map[string]string{"local-folder": dir, "remote-folder": "ROOT"})
if err := cmd.RunE(cmd, nil); err != nil {
t.Fatalf("dry-run sync: %v", err)
}
var payload driveSyncDryRunResult
if err := json.Unmarshal(out.Bytes(), &payload); err != nil {
t.Fatalf("decode dry-run sync: %v\n%s", err, out.String())
}
if payload.Executed || payload.Plan.Summary.Pulled != 0 || payload.Plan.Summary.Pushed != 0 || payload.Plan.Summary.Skipped != 0 {
t.Fatalf("dry-run plan summary = %+v, executed=%v", payload.Plan.Summary, payload.Executed)
}
if payload.Plan.Summary.PlannedPulls != 1 || payload.Plan.Summary.PlannedPushes != 1 ||
payload.Plan.Summary.PlannedSkips != 1 || payload.Plan.Summary.PlannedFolders != 1 {
t.Fatalf("dry-run planned summary = %+v", payload.Plan.Summary)
}
actions := map[string]int{}
for _, item := range payload.Plan.Items {
actions[item.Action]++
}
if len(payload.Plan.Items) != 4 || actions["planned_download"] != 1 || actions["planned_upload"] != 1 ||
actions["planned_skip"] != 1 || actions["planned_folder_create"] != 1 {
t.Fatalf("dry-run action plan = %#v", payload.Plan.Items)
}
for _, tool := range []string{"download_file", "get_upload_info", "commit_upload", "create_folder"} {
if calls := caller.callsFor(tool); len(calls) != 0 {
t.Fatalf("dry-run called %s: %#v", tool, calls)
}
}
}
func TestCrossPlatformCoverageDriveSyncDryRunPlanBranchMatrix(t *testing.T) {
remoteFiles := map[string]*remoteFile{
"conflict.txt": {FileID: "remote-file"},
}
remoteFolders := map[string]string{"": "root", "existing": "existing-id"}
localDirs := []string{"existing", "new", "missing/child"}
localFiles := map[string]localPushFile{"conflict.txt": {}, "new/file.txt": {}}
for _, policy := range []string{syncConflictRemoteWins, syncConflictLocalWins, syncConflictKeepBoth, syncConflictAsk, syncConflictSkip} {
t.Run(policy, func(t *testing.T) {
res := &driveSyncResult{}
appendDriveSyncDryRunPlan(res, driveSyncDryRunPlanInput{
LocalDirs: localDirs, LocalByRel: localFiles, RemoteFiles: remoteFiles, RemoteFolders: remoteFolders,
NewLocal: []string{"new/file.txt", "missing/file.txt"}, NewRemote: []string{"remote.txt"},
Modified: []string{"conflict.txt"}, Unknown: []string{"unknown.txt"}, OnConflict: policy,
})
if res.Summary.PlannedSkips == 0 || res.Summary.PlannedFolders == 0 || res.Summary.PlannedPulls == 0 || res.Summary.Failed != 2 {
t.Fatalf("dry-run plan summary for %s = %#v; items=%#v", policy, res.Summary, res.Items)
}
switch policy {
case syncConflictRemoteWins, syncConflictKeepBoth:
if res.Summary.PlannedPulls < 2 {
t.Fatalf("%s planned pulls = %d", policy, res.Summary.PlannedPulls)
}
case syncConflictLocalWins:
if res.Summary.PlannedPushes < 2 {
t.Fatalf("local wins planned pushes = %d", res.Summary.PlannedPushes)
}
}
})
}
}
func TestCrossPlatformCoverageDrivePull_dryRunPlanBranches(t *testing.T) {
root := t.TempDir()
mustWrite(t, filepath.Join(root, "skip.txt"), "skip")
@@ -242,7 +353,7 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
[]string{"existing", "missing/child"}, files("orphan/a.txt", "skip.txt", "new.txt")); err != nil {
t.Fatalf("skip plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionUploaded) {
if text := out.String(); !strings.Contains(text, pushActionFailed) || !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedUpload) {
t.Fatalf("skip plan did not cover failed/skipped/uploaded: %s", text)
}
out.Reset()
@@ -250,14 +361,14 @@ func TestCrossPlatformCoverageDrivePush_dryRunPlanBranches(t *testing.T) {
files("smart-skip.txt", "smart-overwrite.txt")); err != nil {
t.Fatalf("smart plan: %v", err)
}
if text := out.String(); !strings.Contains(text, pushActionSkipped) || !strings.Contains(text, pushActionOverwritten) {
if text := out.String(); !strings.Contains(text, pushActionPlannedSkip) || !strings.Contains(text, pushActionPlannedOverwrite) {
t.Fatalf("smart plan did not cover skip/overwrite: %s", text)
}
out.Reset()
if err := printDrivePushDryRun(ifExistsOverwrite, remoteFiles, remoteFolders, nil, files("overwrite.txt")); err != nil {
t.Fatalf("overwrite plan: %v", err)
}
if !strings.Contains(out.String(), pushActionOverwritten) {
if !strings.Contains(out.String(), pushActionPlannedOverwrite) {
t.Fatalf("overwrite plan must overwrite existing file: %s", out.String())
}
}
@@ -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) {
+9 -1
View File
@@ -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
+237 -1
View File
@@ -8,6 +8,7 @@ import (
"io"
"strconv"
"strings"
"time"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
@@ -322,6 +323,95 @@ func newOAAttachmentCommand() *cobra.Command {
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,7 +419,8 @@ 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,
// start_process_instance, get_process_instances_by_admin,
// get_attachment_download_url, auth_download_file,
// auth_preview_attachment
// ──────────────────────────────────────────────────────────
@@ -1432,6 +1523,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 <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 <code> --start \"2026-03-10T00:00:00+08:00\" --cursor 0 --limit 20",
"dws oa approval list-by-admin --process-code <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 <processCode> --form-values '{\"事由\":\"测试\"}' --yes",
@@ -1610,6 +1824,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 +1891,7 @@ func newOaCommand() *cobra.Command {
approvalRevertTaskCmd,
approvalFormSchemaCmd,
approvalForecastCmd,
approvalListByAdminCmd,
approvalCreateCmd,
)
approvalCmd.AddCommand(newOAAttachmentCommand())
+90
View File
@@ -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{}
+678
View File
@@ -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 <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
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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)
+11
View File
@@ -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}
})
}
+6 -1
View File
@@ -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,
})
},
+25 -2
View File
@@ -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 })
@@ -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
}
@@ -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
}
+11
View File
@@ -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",
+65 -4
View File
@@ -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)
}
+31 -19
View File
@@ -91,22 +91,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 +125,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 +199,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),
@@ -567,8 +579,8 @@ func evaluateFlagMigrationLifecycle(
}
}
if allReferencesAfter {
if exists {
return nil, fmt.Errorf("consumed flag migration %s is stale after all references reached the after state", approved.displayKey())
if exists && proposed.State != FlagMigrationConsumed {
return nil, fmt.Errorf("candidate changed consumed flag migration %s back to pending", approved.displayKey())
}
continue
}
@@ -602,12 +602,12 @@ func TestCrossPlatformCoverageCompareAllWithFlagMigrationsLifecycleErrors(t *tes
wantErr: "back to pending",
},
{
name: "consumed receipt becomes stale",
name: "inert consumed receipt cannot revert to pending",
current: after,
references: map[string]Snapshot{"merge-base": after, "stable": after},
authority: consumed,
candidate: consumed,
wantErr: "is stale after all references reached the after state",
candidate: pending,
wantErr: "back to pending",
},
{
name: "candidate-added receipt starts pending",
@@ -857,15 +857,15 @@ func TestCrossPlatformCoverageOptionalFlagMigrationLifecycleRemainsHostile(t *te
}
})
t.Run("consumed receipt remains stale after every reference converges", func(t *testing.T) {
_, err := CompareAllWithFlagMigrations(
t.Run("consumed receipt becomes inert after every reference converges", func(t *testing.T) {
report, err := CompareAllWithFlagMigrations(
after,
map[string]Snapshot{"merge-base": after, "stable": after},
consumed,
consumed,
)
if err == nil || !strings.Contains(err.Error(), "stale after all references reached the after state") {
t.Fatalf("stale optional migration error = %v", err)
if err != nil || !report.Compatible {
t.Fatalf("inert optional migration = (%#v, %v), want compatible", report, err)
}
})
}
+22 -15
View File
@@ -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
}
+94 -4
View File
@@ -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(
+5 -13
View File
@@ -403,11 +403,7 @@ var BaseCopy = shortcut.Shortcut{
},
Tips: []string{`dws aitable +base-copy --base-id BASE_ID --target-folder-id FOLDER_ID`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("copy_base", map[string]any{
"baseId": rt.Str("base-id"),
"targetFolderId": rt.Str("target-folder-id"),
"onlyCopyMeta": rt.Bool("only-struct"),
})
return executeBaseCopy(rt)
},
}
@@ -660,7 +656,7 @@ var RecordQuery = shortcut.Shortcut{
Command: "+record-query",
Product: serverMain,
Description: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。",
Intent: "读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。",
Risk: shortcut.RiskRead,
Safety: contract.SafetySpec{
Effect: "read", Risk: "low",
@@ -682,7 +678,7 @@ var RecordQuery = shortcut.Shortcut{
},
Selection: contract.SelectionSpec{
AgentSummary: "查询表格记录(按 ID / 条件 / 关键词,并支持字段投影和分页)",
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗。"},
UseWhen: []string{"读取表格行数据;可按 recordId 精确取、按条件筛选、按关键词搜索,并用 fieldIds 只返回用户要求的字段以避免无关数据和 token 消耗;服务端未返回的计算字段不会由 CLI 本地补算。"},
AvoidWhen: []string{"需要该 Shortcut 未公开的底层参数、原始响应或不同执行语义时,改用对应原子命令"},
Examples: []string{
"dws aitable +record-query --base-id B --table-id T --query \"关键词\" --limit 50",
@@ -975,11 +971,7 @@ var RecordPrimaryDocGet = shortcut.Shortcut{
},
Tips: []string{`dws aitable +record-primary-doc-get --base-id B --table-id T --record-id R`},
Execute: func(rt *shortcut.RuntimeContext) error {
return rt.CallMCP("get_primary_doc", map[string]any{
"baseId": rt.Str("base-id"),
"tableId": rt.Str("table-id"),
"recordId": rt.Str("record-id"),
})
return executeRecordPrimaryDocGet(rt)
},
}
@@ -2078,7 +2070,7 @@ func workflowListProject(data map[string]any) ([]map[string]any, error) {
return nil, fmt.Errorf("list_workflows response item %d must be an object, got %T", index, item)
}
row := map[string]any{}
if v, ok := workflowListFirst(m, "workflowId", "workflow_id", "id"); ok {
if v, ok := workflowListFirst(m, "workflowId", "flowId", "workflow_id", "id"); ok {
row["workflowId"] = v
}
if v, ok := workflowListFirst(m, "name", "workflowName", "title"); ok {
@@ -79,6 +79,154 @@ func TestCrossPlatformCoverageBaseSchemaSnapshotE2E(t *testing.T) {
})
}
func TestCrossPlatformCoverageBaseCopyRequiresNewIDAndExactReadBackE2E(t *testing.T) {
t.Run("verified copy", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"FOLDER"}}`},
{text: `{"data":{"newBaseId":"new-base"}}`},
{text: `{"data":{"baseId":"new-base","tables":[]}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err != nil || !strings.Contains(out, `"newBaseId": "new-base"`) || len(caller.calls) != 3 || caller.calls[0].product != "drive" || caller.calls[0].tool != "get_file_info" || caller.calls[2].tool != "get_base" {
t.Fatalf("base copy = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("missing new ID is unknown without read-back", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"success":true}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "aitable_composite_unknown" || typed.Retryable {
t.Fatalf("missing newBaseId = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("explicit downstream target rejection is stable and not retryable", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("Invalid target folder ID")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 2 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("rejected target = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects a non-folder target before write", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"file-1","type":"FILE"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "file-1", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_wrong_type" || typed.Retryable || typed.ExecutionStarted == nil || *typed.ExecutionStarted {
t.Fatalf("wrong target type = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects target metadata without a supported type", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"fileId":"folder"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("unsupported target metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects URL target before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "https://example.test/folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("URL target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("rejects pure numeric dentryId before transport", func(t *testing.T) {
caller := &upsertByKeyCaller{}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "123456789", "--yes")
if err == nil || out != "" || len(caller.calls) != 0 {
t.Fatalf("numeric target = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("nodeId-only metadata cannot prove dentryUuid", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"data":{"nodeId":"folder","type":"FOLDER"}}`}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
var typed *apperrors.Error
if err == nil || out != "" || len(caller.calls) != 1 || !errors.As(err, &typed) || typed.Reason != "target_not_supported" || typed.Retryable {
t.Fatalf("nodeId-only metadata = output:%q err:%#v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageBaseCopyFailureAndDryRunEdgesE2E(t *testing.T) {
t.Run("invalid source", func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{}, "+base-copy", "--base-id", "bad/id", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" {
t.Fatalf("invalid source = output:%q err:%v", out, err)
}
})
t.Run("dry run", func(t *testing.T) {
caller := &upsertByKeyCaller{dryRun: true}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--dry-run")
if err != nil || len(caller.calls) != 0 || !strings.Contains(out, `"status": "planned"`) {
t.Fatalf("dry run = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("target lookup error", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{err: context.Canceled}}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || !strings.Contains(err.Error(), context.Canceled.Error()) || out != "" || len(caller.calls) != 1 {
t.Fatalf("target error = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("generic copy error is unknown", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{err: errors.New("transport failed")},
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 2 {
t.Fatalf("copy error = output:%q err:%v", out, err)
}
})
readBackCases := []struct {
name string
step upsertByKeyStep
}{
{name: "transport", step: upsertByKeyStep{err: context.DeadlineExceeded}},
{name: "missing id", step: upsertByKeyStep{text: `{}`}},
{name: "wrong id", step: upsertByKeyStep{text: `{"baseId":"other"}`}},
}
for _, tc := range readBackCases {
t.Run("readback "+tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"data":{"fileId":"folder","type":"folder"}}`},
{text: `{"newBaseId":"new-base"}`},
tc.step,
}}
out, err := runAITableCompositeCLI(t, caller, "+base-copy", "--base-id", "source", "--target-folder-id", "folder", "--yes")
if err == nil || out != "" || len(caller.calls) != 3 {
t.Fatalf("readback %s = output:%q err:%v", tc.name, out, err)
}
})
}
for _, value := range []string{"", "white space", "control\x00"} {
if validCompositeOpaqueID(value) {
t.Errorf("validCompositeOpaqueID(%q) = true", value)
}
}
if pureNumericID("") || pureNumericID("123a") || !pureNumericID("123") {
t.Fatal("pureNumericID edge mismatch")
}
}
func bootstrapFields(count int) []any {
fields := make([]any, 0, count)
for index := 0; index < count; index++ {
+136
View File
@@ -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
}
+142
View File
@@ -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
}
+78 -6
View File
@@ -84,9 +84,43 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
return rt.Output(result)
}
for offset := 0; offset < len(ids); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(ids))
batch := ids[offset:end]
existingIDs, err := queryExistingRecordIDs(rt, baseID, tableID, ids)
if err != nil {
return err
}
existingSet := make(map[string]bool, len(existingIDs))
for _, id := range existingIDs {
existingSet[id] = true
}
missingIDs := make([]string, 0, len(ids)-len(existingIDs))
for _, id := range ids {
if !existingSet[id] {
missingIDs = append(missingIDs, id)
}
}
result.Resolved = map[string]any{
"existingRecordIds": existingIDs,
"missingRecordIds": missingIDs,
}
result.Plan = nil
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
result.Plan = append(result.Plan, compositeStep{
Index: len(result.Plan) + 1, Name: "delete existing record batch", Tool: "delete_records",
Status: "planned", Offset: offset, Count: end - offset,
})
}
if len(existingIDs) == 0 {
result.Status = "unchanged"
result.Executed = false
result.Verification = map[string]any{"status": "verified_absent_before_write", "missingCount": len(missingIDs)}
result.Result = map[string]any{"deletedCount": 0, "missingCount": len(missingIDs), "batchCount": 0}
return rt.Output(result)
}
for offset := 0; offset < len(existingIDs); offset += recordBatchSize {
end := minInt(offset+recordBatchSize, len(existingIDs))
batch := existingIDs[offset:end]
writeData, writeErr := rt.CallMCPWriteDataStrict(serverMain, "delete_records", map[string]any{
"baseId": baseID, "tableId": tableID, "recordIds": batch,
})
@@ -109,7 +143,7 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
step.Status = "unknown"
result.CompletedSteps = append(result.CompletedSteps, step)
result.CompletedCount = offset
result.FailedCount = len(ids) - offset
result.FailedCount = len(existingIDs) - offset
result.Status = "unknown"
if offset > 0 {
result.Status = "partial_success"
@@ -130,11 +164,49 @@ func executeRecordDeleteBatches(rt *shortcut.RuntimeContext) error {
result.CompletedCount = end
result.KnownEffects = append(result.KnownEffects, map[string]any{"tool": "delete_records", "offset": offset, "recordIds": batch})
}
result.Verification = map[string]any{"status": "verified_absent", "verifiedCount": len(ids)}
result.Result = map[string]any{"deletedCount": len(ids), "batchCount": len(result.CompletedSteps)}
result.Verification = map[string]any{
"status": "verified_absent", "verifiedCount": len(existingIDs),
"missingBeforeWriteCount": len(missingIDs),
}
result.Result = map[string]any{
"deletedCount": len(existingIDs), "missingCount": len(missingIDs),
"batchCount": len(result.CompletedSteps),
}
return rt.Output(result)
}
func queryExistingRecordIDs(rt *shortcut.RuntimeContext, baseID, tableID string, ids []string) ([]string, error) {
records, err := queryDeletedRecordsByIDs(rt, baseID, tableID, ids)
if err != nil {
return nil, err
}
wanted := make(map[string]bool, len(ids))
for _, id := range ids {
wanted[id] = true
}
present := make(map[string]bool, len(records))
for index, record := range records {
id := recordID(record)
if id == "" {
return nil, fmt.Errorf("delete preflight record %d is missing recordId", index)
}
if !wanted[id] {
return nil, fmt.Errorf("delete preflight returned unexpected recordId %q", id)
}
if present[id] {
return nil, fmt.Errorf("delete preflight returned duplicate recordId %q", id)
}
present[id] = true
}
existing := make([]string, 0, len(present))
for _, id := range ids {
if present[id] {
existing = append(existing, id)
}
}
return existing, nil
}
func parseRecordIDs(values []string) ([]string, error) {
seen := map[string]bool{}
ids := make([]string, 0, len(values))
@@ -4,11 +4,14 @@
package aitable
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"testing"
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
)
func TestCrossPlatformCoverageRecordObjectAndIDValidation(t *testing.T) {
@@ -90,6 +93,137 @@ func TestCrossPlatformCoverageRecordDeleteDryRunAndDualFailureE2E(t *testing.T)
}
}
func TestCrossPlatformCoverageRecordPrimaryDocPreflightAndNormalizationE2E(t *testing.T) {
t.Run("record not found is classified", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "missing")
var typed *apperrors.Error
if err == nil || out != "" || !errors.As(err, &typed) || typed.Reason != "RESOURCE_NOT_FOUND" {
t.Fatalf("missing primary-doc record = output:%q err:%#v", out, err)
}
})
t.Run("table without primary doc field is normalized", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"text","type":"text"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"text":"x"}}]}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "no_primary_doc_field"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 2 {
t.Fatalf("no primary field = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("empty primary doc cell is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{text: `{"data":{"nodeId":null}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("unassociated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("known helper no-record error is unassociated", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{}}]}`},
{err: errors.New("no record")},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"status": "unassociated"`) || !strings.Contains(out, `"exists": false`) || len(caller.calls) != 3 {
t.Fatalf("no-record primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
t.Run("associated doc is resolved through helper", func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{
{text: `{"fields":[{"fieldId":"primary","type":"primaryDoc"}]}`},
{text: `{"records":[{"recordId":"r1","cells":{"primary":{"associated":true}}}]}`},
{text: `{"data":{"nodeId":"node-1"}}`},
}}
out, err := runAITableCompositeCLI(t, caller, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err != nil || !strings.Contains(out, `"nodeId": "node-1"`) || !strings.Contains(out, `"exists": true`) || len(caller.calls) != 3 || caller.calls[2].tool != "get_primary_doc" {
t.Fatalf("associated primary doc = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
func TestCrossPlatformCoverageRecordPrimaryDocFailureAndShapeEdgesE2E(t *testing.T) {
cases := []struct {
name string
steps []upsertByKeyStep
}{
{name: "fields transport", steps: []upsertByKeyStep{{err: context.Canceled}}},
{name: "missing fields collection", steps: []upsertByKeyStep{{text: `{}`}}},
{name: "primary field missing id", steps: []upsertByKeyStep{{text: `{"fields":[{"type":"primaryDoc"}]}`}}},
{name: "record query error", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {err: context.DeadlineExceeded}}},
{name: "wrong record identity", steps: []upsertByKeyStep{{text: `{"fields":[]}`}, {text: `{"records":[{"recordId":"other"}]}`}}},
{name: "helper unknown error", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {err: errors.New("permission denied")}}},
{name: "helper missing node", steps: []upsertByKeyStep{{text: `{"fields":[{"fieldId":"p","type":"primaryDoc"}]}`}, {text: `{"records":[{"recordId":"r1"}]}`}, {text: `{"data":{"message":"ok"}}`}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, err := runAITableCompositeCLI(t, &upsertByKeyCaller{steps: tc.steps}, "+record-primary-doc-get", "--base-id", "base", "--table-id", "table", "--record-id", "r1")
if err == nil || out != "" {
t.Fatalf("primary doc failure = output:%q err:%v", out, err)
}
})
}
for _, value := range []any{
map[string]any{"exists": false},
map[string]any{"dentryUuid": ""},
map[string]any{"status": "NO_RECORD"},
map[string]any{"data": map[string]any{"result": map[string]any{"status": "unassociated"}}},
} {
if !knownPrimaryDocUnassociatedData(value) {
t.Errorf("known unassociated shape not recognized: %#v", value)
}
}
if knownPrimaryDocUnassociatedData([]any{"unrelated"}) || knownPrimaryDocUnassociatedData(nil) {
t.Fatal("unrelated shapes must not be unassociated")
}
for _, value := range []any{
map[string]any{"metadata": map[string]any{"exists": false}},
map[string]any{"items": []any{map[string]any{"nodeId": nil}}},
} {
if knownPrimaryDocUnassociatedData(value) {
t.Errorf("unrelated nested marker must not be unassociated: %#v", value)
}
}
if got := primaryDocNodeID(map[string]any{"metadata": map[string]any{"nodeId": "wrong"}}); got != "" {
t.Fatalf("unrelated nested nodeId = %q, want empty", got)
}
if got := primaryDocNodeID(map[string]any{"data": map[string]any{"response": map[string]any{"dentryUuid": " node-1 "}}}); got != "node-1" {
t.Fatalf("known envelope nodeId = %q, want node-1", got)
}
}
func TestCrossPlatformCoverageRecordDeleteRejectsMalformedPreflightRecordsE2E(t *testing.T) {
for _, tc := range []struct {
name string
records string
}{
{name: "missing id", records: `[{"cells":{}}]`},
{name: "unexpected id", records: `[{"recordId":"other"}]`},
{name: "duplicate id", records: `[{"recordId":"r1"},{"recordId":"r1"}]`},
} {
t.Run(tc.name, func(t *testing.T) {
caller := &upsertByKeyCaller{steps: []upsertByKeyStep{{text: `{"records":` + tc.records + `}`}}}
out, err := runRecordDeleteCLI(t, caller, []string{"r1"})
if err == nil || out != "" || len(caller.calls) != 1 {
t.Fatalf("delete malformed preflight = output:%q err:%v calls:%#v", out, err, caller.calls)
}
})
}
}
func TestCrossPlatformCoverageRecordBatchVerificationEdgesE2E(t *testing.T) {
record := []map[string]any{{"recordId": "r1", "cells": map[string]any{"f": "new"}}}

Some files were not shown because too many files have changed in this diff Show More