Compare commits
379
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d583247935 | ||
|
|
dfc3b028d7 | ||
|
|
95da8214a1 | ||
|
|
d8a3d5d6fd | ||
|
|
5ed7c3adce | ||
|
|
d1f1ab724b | ||
|
|
67505c6c83 | ||
|
|
61c39efb85 | ||
|
|
5b412ac196 | ||
|
|
c0e579fe6b | ||
|
|
c2ff4ab242 | ||
|
|
3fa85d19c9 | ||
|
|
33b76400bf | ||
|
|
2b417e2f2a | ||
|
|
c0e1ec576a | ||
|
|
2b3f482fdc | ||
|
|
95645e4f2c | ||
|
|
575303a5b0 | ||
|
|
d9b728f8e5 | ||
|
|
6240584ae2 | ||
|
|
cb46823280 | ||
|
|
fdcaa61587 | ||
|
|
3e481d296c | ||
|
|
22f87296cd | ||
|
|
c198d8577d | ||
|
|
33828b7858 | ||
|
|
0c80aa79e5 | ||
|
|
da62d11b35 | ||
|
|
a5d7fd05f1 | ||
|
|
cf13026f48 | ||
|
|
7a858b9732 | ||
|
|
00c337c438 | ||
|
|
27aca3ccc3 | ||
|
|
84036678dd | ||
|
|
d5031a89f0 | ||
|
|
ed1ebe5d03 | ||
|
|
0d22a4a1bd | ||
|
|
83f3b4f385 | ||
|
|
e217901a6b | ||
|
|
13d0ae66a6 | ||
|
|
63854705fd | ||
|
|
f3b0fcdc4c | ||
|
|
109a2891de | ||
|
|
4e106cd5ad | ||
|
|
9858844158 | ||
|
|
00ca448aa2 | ||
|
|
afe01d4b70 | ||
|
|
4c6db326f5 | ||
|
|
f10d552fd7 | ||
|
|
8b8756b00e | ||
|
|
ec59cf8065 | ||
|
|
2ffddbd5a0 | ||
|
|
843edd700d | ||
|
|
58ff0248b6 | ||
|
|
1d3c56f9fa | ||
|
|
502317db68 | ||
|
|
66516755e6 | ||
|
|
6c895c23ed | ||
|
|
6e3f528f48 | ||
|
|
d70e6b85b6 | ||
|
|
5e71a4ea52 | ||
|
|
0793238d47 | ||
|
|
510b120630 | ||
|
|
f253f865c7 | ||
|
|
c8f83533fb | ||
|
|
8e34134dbc | ||
|
|
65885dd262 | ||
|
|
b354b371c9 | ||
|
|
15d289d3f3 | ||
|
|
08e80bcb89 | ||
|
|
39d9a65616 | ||
|
|
a325ca80d8 | ||
|
|
57b5845eb3 | ||
|
|
75f08da197 | ||
|
|
b246b7d83b | ||
|
|
3ac9b83565 | ||
|
|
f4cb8aa282 | ||
|
|
c15480c452 | ||
|
|
234253e75f | ||
|
|
6a4803d85a | ||
|
|
c0b013afa9 | ||
|
|
34d33e0492 | ||
|
|
be15dd05df | ||
|
|
3578e4019b | ||
|
|
ede8e3c555 | ||
|
|
d11e69fbdb | ||
|
|
a3566f39c4 | ||
|
|
a192e988c4 | ||
|
|
7a1b85ab62 | ||
|
|
490818dfe9 | ||
|
|
1ab8f113a5 | ||
|
|
4f4ea43549 | ||
|
|
4fd67c52dc | ||
|
|
a3b06befbc | ||
|
|
290f39ecb8 | ||
|
|
7fbe7593c8 | ||
|
|
9fb61f8e99 | ||
|
|
2287abe644 | ||
|
|
b3991d473e | ||
|
|
32bd2118af | ||
|
|
f290a2101e | ||
|
|
c8490da527 | ||
|
|
f26806bc55 | ||
|
|
91d2e29925 | ||
|
|
26638cbd98 | ||
|
|
2f05649277 | ||
|
|
c53e1f465d | ||
|
|
6c8e7e082b | ||
|
|
176a556355 | ||
|
|
8609963ef8 | ||
|
|
63a6de7b49 | ||
|
|
f57d9a51f4 | ||
|
|
46b641f227 | ||
|
|
9dc7f64b87 | ||
|
|
b334794168 | ||
|
|
5aaf22782c | ||
|
|
089c5491ec | ||
|
|
71da2dfded | ||
|
|
21395ed12d | ||
|
|
7186a69b78 | ||
|
|
9c202c7eae | ||
|
|
2969fb3c21 | ||
|
|
149a2481f4 | ||
|
|
5f2344d16d | ||
|
|
4da2f382ec | ||
|
|
a3a96a6bd4 | ||
|
|
548809f72e | ||
|
|
e79efc70af | ||
|
|
7568d05434 | ||
|
|
6aaa15be3c | ||
|
|
ac8e41aa5f | ||
|
|
57bc1bcea8 | ||
|
|
f1c5a887b6 | ||
|
|
7fa4ee7bac | ||
|
|
7c76e4fc03 | ||
|
|
606f712a52 | ||
|
|
5b9234d8fe | ||
|
|
dac4f6c029 | ||
|
|
619319517a | ||
|
|
b7a6abb780 | ||
|
|
7dab8df861 | ||
|
|
74513bae2f | ||
|
|
288212748c | ||
|
|
ef2c3ac163 | ||
|
|
b057c89a70 | ||
|
|
6ddfa59a28 | ||
|
|
721a40b05e | ||
|
|
fcbddb0904 | ||
|
|
d04511b8a6 | ||
|
|
f913c95ed1 | ||
|
|
effde76227 | ||
|
|
43f0813acd | ||
|
|
33d8cd7e36 | ||
|
|
cfbe5b9b0d | ||
|
|
6e85983ad4 | ||
|
|
edbb175d4e | ||
|
|
b7bc0acb14 | ||
|
|
48e5d603bc | ||
|
|
e84743615f | ||
|
|
a102447eb5 | ||
|
|
2b131a1031 | ||
|
|
12ff9d6138 | ||
|
|
ea18feb0a8 | ||
|
|
5fdaea5f36 | ||
|
|
e8a320a06b | ||
|
|
c5e3c2ec56 | ||
|
|
59268a42a6 | ||
|
|
06661af43f | ||
|
|
951dd27f0c | ||
|
|
bc5e5db7ef | ||
|
|
a0accff258 | ||
|
|
92c80f81f9 | ||
|
|
70ed89c6bf | ||
|
|
f7a3e606f7 | ||
|
|
1d1aca5fd1 | ||
|
|
b199fd29cb | ||
|
|
07b14aa72a | ||
|
|
d245ea4c84 | ||
|
|
93703113cb | ||
|
|
e5ed9e6e39 | ||
|
|
7924e84fb6 | ||
|
|
b4129c467d | ||
|
|
a12abdfb54 | ||
|
|
d9283b9a82 | ||
|
|
f1d40e26e1 | ||
|
|
0db91cfc44 | ||
|
|
f88be7ae21 | ||
|
|
e3313095ba | ||
|
|
c7882d7f72 | ||
|
|
92196738d3 | ||
|
|
0a4da58d8f | ||
|
|
f14332f143 | ||
|
|
62883b7940 | ||
|
|
6e20bc765f | ||
|
|
ecaefb416f | ||
|
|
f16feed896 | ||
|
|
d0a9dad079 | ||
|
|
e6c54cf777 | ||
|
|
7a97354d93 | ||
|
|
a46958c788 | ||
|
|
b664ace2f2 | ||
|
|
a789a2eea7 | ||
|
|
e4a1feccf0 | ||
|
|
3f39a9ddb1 | ||
|
|
b080b6e7c5 | ||
|
|
dc180f6d07 | ||
|
|
7b64576ea1 | ||
|
|
437dd234b2 | ||
|
|
04f78bcb15 | ||
|
|
9abcdb4deb | ||
|
|
c0da89e674 | ||
|
|
09fc5d993d | ||
|
|
8f3a9e9d4a | ||
|
|
567ea5d77c | ||
|
|
fc18f8fd04 | ||
|
|
a39ad4e6af | ||
|
|
301429e3aa | ||
|
|
0af5751d75 | ||
|
|
79f7ee80e0 | ||
|
|
44d640bbae | ||
|
|
06b4f3ba31 | ||
|
|
9f983be1ac | ||
|
|
9e3a5d6fbd | ||
|
|
33623d09d9 | ||
|
|
b20055a0b5 | ||
|
|
caf81b7984 | ||
|
|
fc05976d33 | ||
|
|
021da02474 | ||
|
|
a5b9e5a13f | ||
|
|
5742239c74 | ||
|
|
3dce49020e | ||
|
|
80bca147e0 | ||
|
|
4ec2635830 | ||
|
|
f319906f29 | ||
|
|
fac92c252e | ||
|
|
9f8c525008 | ||
|
|
207d4dd7e5 | ||
|
|
8db297fe4b | ||
|
|
dc2aec7696 | ||
|
|
9a6b7d4d41 | ||
|
|
2cea069f55 | ||
|
|
404af112b7 | ||
|
|
5947016cc1 | ||
|
|
5425d1565f | ||
|
|
386426bb92 | ||
|
|
1a58e3c3e6 | ||
|
|
208a6c0273 | ||
|
|
3cea671a54 | ||
|
|
4e8469a175 | ||
|
|
9d8b338833 | ||
|
|
ea92e0212b | ||
|
|
b7a07abcb1 | ||
|
|
f06ea4d9e2 | ||
|
|
2292a49c6a | ||
|
|
a82d945f54 | ||
|
|
6846326445 | ||
|
|
54c2054a5c | ||
|
|
e5bf332b05 | ||
|
|
9ed55978d9 | ||
|
|
7ffbbc4a51 | ||
|
|
2db73a8185 | ||
|
|
a62332be93 | ||
|
|
1083093cbc | ||
|
|
c6ebe307cd | ||
|
|
3ee66d4373 | ||
|
|
cf43cf1b47 | ||
|
|
344104268a | ||
|
|
a0be395ccc | ||
|
|
93dbd768f7 | ||
|
|
c1a549cd64 | ||
|
|
5a414999ef | ||
|
|
7aa8240629 | ||
|
|
37b9a1dc31 | ||
|
|
2ab8748c4d | ||
|
|
f041275811 | ||
|
|
f486105836 | ||
|
|
e14de2b4c2 | ||
|
|
fe2f3ca92f | ||
|
|
8e4519cacd | ||
|
|
89027aa2e2 | ||
|
|
857279e076 | ||
|
|
e45608bb13 | ||
|
|
ff6e2347f6 | ||
|
|
2d29f6601b | ||
|
|
fa887ccd26 | ||
|
|
16abb481e8 | ||
|
|
7ad82bbf0a | ||
|
|
30202e2b81 | ||
|
|
1203409185 | ||
|
|
104eb715c4 | ||
|
|
97ea887ea5 | ||
|
|
0ba55350d8 | ||
|
|
14c2569cfb | ||
|
|
03838a3430 | ||
|
|
bfeb9f6af0 | ||
|
|
e26f278112 | ||
|
|
0d34150333 | ||
|
|
afb90009f6 | ||
|
|
9e8b58cbb6 | ||
|
|
31117d1b89 | ||
|
|
e3553fe7a5 | ||
|
|
fe724e96e8 | ||
|
|
067aff179f | ||
|
|
353454abb2 | ||
|
|
ae1565c0ff | ||
|
|
a9a97c2746 | ||
|
|
f72979f4a9 | ||
|
|
7581955892 | ||
|
|
9fbd8addbe | ||
|
|
92195a58a3 | ||
|
|
24bbdda423 | ||
|
|
0b012788c7 | ||
|
|
276658590b | ||
|
|
16d20b8178 | ||
|
|
dd89c67f4d | ||
|
|
75bf44b7be | ||
|
|
1bae872341 | ||
|
|
e742a6c269 | ||
|
|
d1ecdcd551 | ||
|
|
a47740ca1c | ||
|
|
05868610f0 | ||
|
|
22649e96ef | ||
|
|
f68a11f11d | ||
|
|
4ad321557f | ||
|
|
3f2fc2e5f0 | ||
|
|
abe5129306 | ||
|
|
34dee96833 | ||
|
|
ef27877628 | ||
|
|
7b7bd556e9 | ||
|
|
6a2e9dd10e | ||
|
|
d534ee242c | ||
|
|
fc455f800c | ||
|
|
42b5004bf8 | ||
|
|
3556d28fdd | ||
|
|
3a3cf00072 | ||
|
|
618eb842a2 | ||
|
|
465acf1406 | ||
|
|
ce73a5b452 | ||
|
|
90473284b8 | ||
|
|
175b51cec0 | ||
|
|
53a71b08c7 | ||
|
|
07aa2c883a | ||
|
|
5abef59c7c | ||
|
|
3ef735bb3c | ||
|
|
44e18a4062 | ||
|
|
ccbacf84b3 | ||
|
|
7cfaa1ca74 | ||
|
|
d8f8f29062 | ||
|
|
f8af8dc1dc | ||
|
|
bb6fd2f256 | ||
|
|
580c4d201b | ||
|
|
4f754133a5 | ||
|
|
37cd629335 | ||
|
|
b447aac84b | ||
|
|
9703a21a2d | ||
|
|
0c0e2b3ce1 | ||
|
|
7d16c9693f | ||
|
|
1dee02d900 | ||
|
|
7664f04fda | ||
|
|
8177a06296 | ||
|
|
c674366aea | ||
|
|
ce5815a606 | ||
|
|
d211b79a80 | ||
|
|
de8df0fa6f | ||
|
|
9ff31cdd55 | ||
|
|
fde37f7896 | ||
|
|
31902a987e | ||
|
|
8cd2b0259d | ||
|
|
4b8d94c24e | ||
|
|
76e5a8c4d9 | ||
|
|
6abffce4e5 | ||
|
|
86b78e45d7 | ||
|
|
5ef52503ae | ||
|
|
8ee9fc3f48 | ||
|
|
5065e4bfb6 | ||
|
|
2778bef5bd | ||
|
|
19e19bcd2b | ||
|
|
8aee08268d | ||
|
|
6a4744073c |
@@ -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,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
@@ -0,0 +1,9 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,61 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.59-beta.3] - 2026-08-19
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot group reference replies** (#928) — `chat message send-by-bot` supports paired `--reply` and `--ref-sender` flags for Markdown replies that quote an existing group message.
|
||||
|
||||
- **AI Table server-side statistics** — adds `dws aitable record stats` for
|
||||
ungrouped record-set metrics through `query_records_stats`, plus `dws aitable
|
||||
record group-stats` for grouped, distinct, and advanced aggregation through
|
||||
`query_stats`; both commands validate their JSON aggregation contracts before
|
||||
dispatch.
|
||||
|
||||
- **Calendar event share-info** (#980) — adds `dws calendar event share-info` to fetch a calendar event's share info (title, organizer, location, join info) for sharing with others; supports `--calendar-id` and `--language`.
|
||||
|
||||
- **Calendar and To-do Shortcut workflows** — aligns 47 public task-oriented
|
||||
entries with lark-cli where the DingTalk backend supports equivalent
|
||||
semantics, rejects malformed or missing collections instead of returning
|
||||
false empty success, preserves truthful pagination, and requires stable
|
||||
identifiers plus read-back or explicit terminal receipts for writes. Adds
|
||||
deterministic contract coverage, a PII-safe live E2E runner, and a sanitized
|
||||
capability review with documented platform boundaries.
|
||||
|
||||
- **Doc and Sheet comment lifecycle commands** — adds `comment batch-query`,
|
||||
`comment resolve`, `comment restore`, and the lightweight
|
||||
`comment react-reply` to both `dws doc` and `dws sheet`. The two domains share
|
||||
the same `doc-comment` MCP capabilities; batch queries preserve input order
|
||||
for repeated `topicId:commentKey` references, while reaction replies require
|
||||
DingTalk reaction names such as `憨笑` or `鼓掌` rather than raw Unicode emoji.
|
||||
|
||||
- **Sheet SourceRange dropdowns** — supports range-backed dropdowns across direct, cell, and batch write paths, with structured readback for valid and invalid references. Batch `set-dropdown` now rejects unsupported top-level `colors` / `source-colors`; Inline colors belong in `options[].color`, while SourceRange color writes remain unsupported.
|
||||
- **Sheet read completion metadata** — documents and preserves returned ranges, truncation reasons, and partial-read status for large range and CSV reads.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AI Table parameter aliases** — accepts reviewed equivalent spellings for Base, table, workflow, search, pagination, and description parameters while keeping role-changing or semantically ambiguous inputs blocked.
|
||||
|
||||
- **Doc/drive description scope** — restates the `dingtalk-doc` description as document-entity-and-content operations with an explicit exclusion list, and narrows `dingtalk-drive` to file-level management of DingTalk documents, so first-round Agent selection separates content work from file management without changing CLI behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Aitable pagination and Minutes unshare verification** (#1006) — keeps
|
||||
record queries on the service's 20-record page boundary so multi-page reads
|
||||
and mutation readbacks no longer report false retryable failures, preserves
|
||||
`totalCount` when supplied, validates `--dry-run` plans before transport,
|
||||
follows active deletion readback continuations before proving absence, and
|
||||
rejects Minutes unshare success until the listening note exists and the
|
||||
service acknowledges the exact task and member targets.
|
||||
|
||||
- **Document write verification** (#960) — avoids false partial-success results when normalized Markdown, paginated blocks, inline images, or version reverts are confirmed by server readback. Document reverts and media inserts now require explicit readback evidence and report partial success when the server cannot prove the requested result.
|
||||
|
||||
- **Chat sender identity guards** — preserves unverified mixed sender inputs after exact message `senderId` matches and aligns `--sender-query` Skill guidance with fail-closed Runtime behavior.
|
||||
|
||||
- **Windows event bus lifecycle** — start event consumers without unsupported inherited file descriptors, stop buses through local IPC with a termination fallback, and preserve subscription cleanup when startup fails.
|
||||
|
||||
|
||||
## [1.0.59-beta.2] - 2026-08-17
|
||||
|
||||
### Added
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
@@ -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.1"
|
||||
version "1.0.59-beta.3"
|
||||
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.1/dws-darwin-arm64.tar.gz"
|
||||
sha256 "36a30f3496e0f759c15c0b09f67dbd23b8ecdfff2eebe572f88125b26485830f"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-arm64.tar.gz"
|
||||
sha256 "9c99adcefd9104368eb443f0a1b4af8e7aceaa1ffdd4462e486854c1692bb6ce"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-darwin-amd64.tar.gz"
|
||||
sha256 "e7a04906380efd8da88cd112e6a512bb6470a3956dc370150037ed6e314db445"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-darwin-amd64.tar.gz"
|
||||
sha256 "f5cc8efb1f982d68ae549190fd683292359c2ab542b532fa52bb35e6b5c049af"
|
||||
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.1/dws-linux-arm64.tar.gz"
|
||||
sha256 "f59ab055f3e841e4cebc964ae3ef969668475548abaaf8bede44afdca9a3e28d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-arm64.tar.gz"
|
||||
sha256 "7a4efd04b417ce8013b1e431274b396179958da244164f59974358ba327ff093"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-linux-amd64.tar.gz"
|
||||
sha256 "2c8f919489d958c7d49262615e81faac70a9fbcae2d589ab54a0bb3c5700a057"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-linux-amd64.tar.gz"
|
||||
sha256 "90181e8f2e9010c1943a5773c3d45d7d3ac85d6bc93e18a9aaa7c69909e553d7"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.1/dws-skills.zip"
|
||||
sha256 "25f4a7e1d01fa4d771d79201b34b11ee8a24182bdcdc94bfb98d2bd5845bed3b"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.3/dws-skills.zip"
|
||||
sha256 "e7028914a4a826af9b18ed4922d68fa8f279473817fed4f305465bc8a7aad363"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -210,7 +210,7 @@ The verifier uses isolated directories and does not replace the `dws` on the cur
|
||||
The upgrade process follows a two-phase atomic flow to ensure consistency:
|
||||
|
||||
1. **Prepare** — downloads the platform-specific binary and skill packages to a temporary directory, verifies SHA256 checksums, and extracts/validates all files. If any step fails, the upgrade aborts without modifying the existing installation.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into detected agent-specific roots (for example `~/.codex/skills/dingtalk-chat`). `~/.agents/skills` is used only when no specific Agent is detected; once a specific root is active, older DWS-managed generic copies are backed up and retired so the same Skill is not discovered twice.
|
||||
2. **Apply** — only after all preparations succeed, the binary is replaced and skills are flattened into the canonical `~/.agents/skills` root. Agents classified by the pinned compatibility registry as supporting the universal root read it directly; other detected Agents receive links to the canonical copy, with a direct-copy fallback when links are unavailable. Older DWS-managed agent-specific copies are backed up and retired so the same Skill is not discovered twice.
|
||||
|
||||
A backup of the current version is automatically created before each upgrade. Use `dws upgrade --rollback` to restore the previous version if needed.
|
||||
|
||||
@@ -405,7 +405,7 @@ After installing, AI tools like Claude Code / Cursor can operate DingTalk direct
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install-skills.sh | sh
|
||||
```
|
||||
|
||||
> Installers prefer detected agent-specific roots such as `$HOME/.codex/skills/`. They use `.agents/skills/` only as the generic fallback when no specific Agent is detected; multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
> Installers use `$HOME/.agents/skills/` as the canonical global store, following the universal `.agents/skills` convention. Agents classified by the pinned compatibility registry as universal read that root directly; detected non-universal Agents receive links to it (or copies when links are unavailable). Multi layout is per-product siblings, while mono uses the `dws/` subdirectory.
|
||||
>
|
||||
> China users: prefix `DWS_GITEE_REPO` to use the Gitee mirror — see [China mirror](#china-mirror).
|
||||
|
||||
@@ -482,7 +482,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and six OA approval task/instance events.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, group lifecycle events, and seven OA approval task/instance events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -530,12 +530,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# Listen for all six public OA approval events in one process
|
||||
# Listen for all seven public OA approval events in one process
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+3
-2
@@ -476,7 +476,7 @@ multi setup 或 upgrade 后,DWS 会把官方 bundle 快照和统一所有权
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及六个 OA 审批任务/实例事件。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应、群生命周期,以及七个 OA 审批任务/实例事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -524,12 +524,13 @@ dws event consume user_im_group_disbanded --group <openConversationId> --flatten
|
||||
dws event +listen-im --kind sender --user <userId> \
|
||||
--events message,read,recall -f ndjson
|
||||
|
||||
# 一个进程监听全部六个公开 OA 审批事件
|
||||
# 一个进程监听全部七个公开 OA 审批事件
|
||||
dws event consume \
|
||||
user_oa_approval_task_created \
|
||||
user_oa_approval_task_finished \
|
||||
user_oa_approval_task_redirected \
|
||||
user_oa_approval_instance_started \
|
||||
user_oa_approval_instance_cc \
|
||||
user_oa_approval_instance_terminated \
|
||||
user_oa_approval_instance_finished \
|
||||
--flatten -f ndjson
|
||||
|
||||
+1214
-126
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,6 @@
|
||||
"README.md"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16"
|
||||
"node": ">=16.7.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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]")
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
|
||||
@@ -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,9 +41,54 @@ 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。当前清单登记了 IM ID rename 的 `pending` 记录;`pending` 只记录已评审计划,候选与 merge-base 仍必须精确匹配 `before`,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
清单使用严格 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` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
`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 → 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。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
|
||||
@@ -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. 后续演进
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"generated_at": "2026-08-14T13:50:36.324505",
|
||||
"count": 418,
|
||||
"generated_at": "2026-08-18T17:38:50.904696",
|
||||
"count": 436,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -489,7 +489,7 @@
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一 ID、筛选、关键词和分页查询,并投影稳定的 records/count/cursor 结果。",
|
||||
"semantic_delta": "统一 ID、筛选、关键词、字段投影和分页查询;fieldIds 只返回用户要求的列,并投影稳定的 records/count/cursor 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
@@ -692,6 +692,16 @@
|
||||
"semantic_delta": "按当前父目录语义调整文件夹展示顺序。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+table-bootstrap",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在已有 Base 中声明式创建一张表和字段,字段自动分片,逐层读回验证并发布可恢复检查点。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -1056,144 +1066,274 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+agenda",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 result.events 与分页证据,稳定投影 id 为 eventId;显式空数组才是空日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+attendee-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证参会人数组并投影显示名、响应状态和 self;真实后端不返回稳定参会人 userId,因此不伪造身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按参会人姓名解析后创建日程,并在多步骤路径提供失败回滚与详情读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证日历本数组并稳定投影 calendarId,缺失数组不再误报为空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+book-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按日历本名称搜索并严格区分零命中与协议错误。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+cancel-event",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "删除前读取并展示目标,经高风险确认后取消指定日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+conflicts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在指定范围分析重叠日程并输出冲突对,避免人工逐项比对。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建响应必须给出 eventId/id,且标题和起止时间通过详情读回后才成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析参与者后查询闲忙,省去手工 userId 解析。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+free-slots",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "从已有日程计算工作时段内空档,并显式输出满足时长的候选区间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+freebusy",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "统一按用户或会议室 ID 查询指定范围闲忙,并声明至少一类目标约束。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "严格读取日程对象并把稳定 id 规范化为 eventId,拒绝空响应和对象漂移。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+invite",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析用户后邀请到已有日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+my-free",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "以当前用户身份查询一个时间段是否空闲。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+next-event",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在未来时间窗内定位最近的下一场日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+reschedule",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "修改前读取目标日程,再更新完整起止时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-find",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按单个未来时间段严格查询可用会议室,公开真实 page/pageSize/hasMore 证据和后端筛选边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-groups",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格读取 result.groupList,避免会议室分组被响应 wrapper 漂移静默清空。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+room-search",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按名称查询会议室但不声称检查时间可用性;缺失会议室数组直接失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+rsvp",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "把直白 RSVP 动作映射为 responseStatus,拒绝空 ack,并在写后读取日程终态。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+search-event",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在服务端日程当前页按标题、描述和地点过滤,同时保留 hasMore/nextCursor,绝不把单页零命中当全局未找到。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggest-time",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名逐个唯一解析参与者,再查询大家共同可用的建议时间。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+suggestion",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "直接按 userId 查询建议时段,严格区分显式空 recommendEventTimes 与缺失或畸形响应。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+today",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理今天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+tomorrow",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并整理明天的日程视图。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "组合字段与参会人分阶段更新,明确非事务步骤并以详情和参会人列表读回验证。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "calendar",
|
||||
"command": "+week",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "查询并按日期整理本周日程。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3582,81 +3722,214 @@
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "保留历史按姓名解析后创建并指派的新任务语义,避免同名破坏性变更。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+assign-multi",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "全部姓名唯一解析后才创建一次,并以单一统一结果输出稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+comment",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "新增评论必须取得稳定 commentId,并分页读回验证内容。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+complete",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时完成后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "创建后要求稳定 taskId,并读取 todoDetailModel 验证标题。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+created-todos",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历创建者角色的全部分页后投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+due-today",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按本地日历日构造服务端截止窗口并遍历全部分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格绑定 todoDetailModel.taskId,缺失或错绑均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-my-tasks",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格识别 todoCards/hasMore/page/size;显式空数组才是空结果,并支持有界全量分页。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+get-related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历与我相关三种角色的全部分页并按 taskId 去重。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-attachment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空附件与缺失容器,并要求每项稳定附件 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-comment",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格区分显式空评论与缺失容器,并要求每项稳定 commentId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+list-sub",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验子待办容器、对象元素和稳定 taskId。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+overdue",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历执行者角色的全部分页并按当前时间筛选逾期项。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+related-tasks",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "alias_internal",
|
||||
"semantic_delta": "保留旧命令拼写,新的 Agent 路由统一使用 +get-related-tasks。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+remind",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "解析当前用户后创建给自己的待办;该命令不是独立提醒规则。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reminder",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证提醒参数和 success=true 终端回执;上游不能读回规则,固定 verified=false。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+reopen",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "先读当前状态避免重复写,必要时重开后再读回核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "遍历全部 hasMore 分页并按标题匹配;达到页上限仍未耗尽时失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+todo-done",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "跨全部分页按标题唯一定位后完成任务。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "todo",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
|
||||
@@ -102,7 +102,7 @@
|
||||
<tr><td><code>minutes +latest-minutes</code></td><td>列妙记→取最新一条详情</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>minutes +action-items</code></td><td>列妙记→取最新→取其待办</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>wiki +wiki-new-doc --space <名></code></td><td>按名搜知识空间→建文档(跨 doc server 路由)</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --text</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +doc-append --doc --content</code></td><td>文档末尾追加文本</td><td class="c ok">编译/挂载</td></tr>
|
||||
<tr><td><code>doc +share-doc --to <名> --url</code></td><td>解析人→把文档链接私信 TA(跨服务)</td><td class="c ok">编译/挂载</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -56,13 +56,13 @@
|
||||
|
||||
| shortcut | 多步/智能逻辑 | 验证 |
|
||||
|----------|--------------|------|
|
||||
| `chat +dm --to <姓名> --text` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `chat +dm --to <姓名> --content` | 搜人→解析唯一 userId→发单聊;多人消歧 | ✅ dry-run 真机 |
|
||||
| `contact +lookup --name <姓名>` | 搜人→解析 userId→取完整资料 | ✅ **真机端到端** |
|
||||
| `todo +assign --to <姓名> --task` | 解析人→建待办并把 TA 设为执行人 | ✅ dry-run 真机 |
|
||||
| `chat +send-to-group --group <群名> --text` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `chat +send-to-group --group <群名> --content` | 按群名搜群(search_groups)→消歧→发消息 | ✅ 编译/挂载 |
|
||||
| `calendar +book --title --start --end [--with <姓名CSV>]` | 建日程→按名加参与者→**失败回滚删日程**(对标 lark `calendar +create`) | ✅ dry-run 真机 |
|
||||
| `calendar +free --who <姓名> --start --end` | 解析人→查其时段忙闲 | ✅ **真机端到端**(解析 202397→查忙闲) |
|
||||
| `chat +broadcast --to <姓名CSV> --text` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `chat +broadcast --to <姓名CSV> --content` | 多名逐一解析→群发单聊,失败汇总不中断 | ✅ 编译/挂载 |
|
||||
| `minutes +latest-minutes` | 列妙记→取最新一条详情 | ✅ 编译/挂载 |
|
||||
| `chat +group-members --group <群名>` | 按群名搜群→列群成员 | ✅ 编译/挂载 |
|
||||
| `contact +org --name <姓名>` | 解析人→取详情拿 deptId→查部门详情 | ✅ **真机端到端**(3 步:董鑫阳→模型算法/16人) |
|
||||
@@ -76,7 +76,7 @@
|
||||
| `todo +todo-done --task <关键词>` | 列我的待办→按标题匹配→标记完成 | ✅ 编译/挂载 |
|
||||
| `calendar +reschedule --event <id>` | 查日程详情→改时间(查→改机械多步) | ✅ 编译/挂载 |
|
||||
| `wiki +wiki-new-doc --space <名>` | 按名搜知识空间→在其下建文档(跨 doc server 路由) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --text` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `doc +doc-append --doc --content` | 文档末尾追加文本(update_document append 模式) | ✅ 编译/挂载 |
|
||||
| `minutes +action-items` | 列妙记→取最新→取其待办事项 | ✅ 编译/挂载 |
|
||||
| `minutes +detail --id <taskUuid>` | 一条命令聚合听记 basic/summary/keywords/transcript/todos,partial-failure 容错 | ✅ 全量测试 |
|
||||
| `minutes +replace-batch --id --pair "原文=>替换"…` | 多组批量替换文字,去重校验+逐组结果聚合 | ✅ 全量测试 |
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
# OA Attachment Download URL Output Design
|
||||
|
||||
## Goal
|
||||
|
||||
Keep the existing command and MCP request unchanged while making the returned
|
||||
OSS signed URL directly copyable from JSON output:
|
||||
|
||||
```text
|
||||
dws oa approval attachment download-url
|
||||
```
|
||||
|
||||
## Scope
|
||||
|
||||
Only `oa approval attachment download-url` changes. The other OA attachment
|
||||
commands and the global JSON formatter retain their current behavior.
|
||||
|
||||
## Design
|
||||
|
||||
The command continues to invoke MCP server `oa`, tool
|
||||
`get_attachment_download_url`, with the same arguments. Its leaf declaration
|
||||
provides a command-specific `Call` callback that invokes the existing MCP
|
||||
dispatcher with HTML escaping disabled when the selected output format is
|
||||
JSON. This preserves literal `&` separators in `result.downloadUri` instead of
|
||||
rendering them as `\u0026`.
|
||||
|
||||
For `raw`, `table`, and other non-JSON formats, the callback uses the existing
|
||||
escaped dispatcher behavior so their current rendering remains unchanged.
|
||||
|
||||
The change does not alter the URL, decode or re-sign it, download the file, or
|
||||
change global JSON serialization.
|
||||
|
||||
## Error Handling
|
||||
|
||||
Authentication, MCP transport, gateway, PAT, and business errors continue
|
||||
through the existing dispatcher and retain their current classification and
|
||||
output behavior.
|
||||
|
||||
## Verification
|
||||
|
||||
Add a `TestCrossPlatformCoverage*` regression test that executes the real Cobra
|
||||
leaf in explicit JSON mode with a fake MCP result containing a signed URL. It
|
||||
must verify:
|
||||
|
||||
- the request still targets `oa/get_attachment_download_url`;
|
||||
- the exact request arguments remain unchanged, including omission of the
|
||||
optional boolean when the flag was not supplied;
|
||||
- stdout contains literal `&OSSAccessKeyId=` and `&Signature=`;
|
||||
- stdout contains no `\u0026` escape.
|
||||
|
||||
The fake caller must report JSON format (or the command must be executed with
|
||||
`--format json`) so the test fails against the current escaped JSON path rather
|
||||
than accidentally exercising raw MCP text output.
|
||||
|
||||
Run the focused OA attachment tests, format modified Go files, and rebuild the
|
||||
CLI. No commit is created.
|
||||
@@ -18,6 +18,7 @@ require (
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-beta.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/yuin/goldmark v1.8.5
|
||||
github.com/zalando/go-keyring v0.2.8
|
||||
gitlab.alibaba-inc.com/aes/aem-go-sdk v0.3.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
|
||||
@@ -105,6 +105,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
github.com/yuin/goldmark v1.8.5 h1:r6N5afV5qj/5S4UTch8agZHJ8UxNCMwX7WjkkJam2NA=
|
||||
github.com/yuin/goldmark v1.8.5/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs=
|
||||
github.com/zalando/go-keyring v0.2.8/go.mod h1:tsMo+VpRq5NGyKfxoBVjCuMrG47yj8cmakZDO5QGii0=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
|
||||
@@ -403,7 +403,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "消费 OA、群生命周期或需要底层控制的个人事件流;Agent 通常使用 --flatten 输出 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要监听六个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听七个公开 OA 审批任务/实例 EventKey 中的一个或多个事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"用户显式给出原始 EventKey、Filter DSL、subscribe_id,要求原始 transport envelope,或需要普通 IM facade 不提供的高级多事件控制",
|
||||
},
|
||||
@@ -1220,7 +1220,8 @@ func newEventStopCommandWithFlags(globalFlags ...*GlobalFlags) *cobra.Command {
|
||||
editionName := editionNameOrDefault()
|
||||
clientIDHash := dwsevent.ClientIDHash(clientID)
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindAppStream, clientIDHash)
|
||||
if err := eventStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
fmt.Fprintln(c.OutOrStdout(), "bus is not running")
|
||||
return nil
|
||||
|
||||
@@ -152,8 +152,8 @@ func TestCrossPlatformCoveragePersonalSubscriptionProtectionCoversAllPublicEvent
|
||||
}
|
||||
}
|
||||
|
||||
if publicCount != 22 {
|
||||
t.Fatalf("public personal events = %d, want 22 (16 IM + 6 OA)", publicCount)
|
||||
if publicCount != 23 {
|
||||
t.Fatalf("public personal events = %d, want 23 (16 IM + 7 OA)", publicCount)
|
||||
}
|
||||
for _, ruleType := range []string{"at", "all", "singleChat", "sender", "group"} {
|
||||
if !ruleTypes[ruleType] {
|
||||
|
||||
@@ -1220,7 +1220,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
}
|
||||
|
||||
busState := "personal bus stopped"
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir}); err != nil {
|
||||
if err := personalStopBus(busctl.StopConfig{WorkDir: workDir, IPCEndpoint: ipcEndpoint}); err != nil {
|
||||
if errors.Is(err, busctl.ErrNotRunning) {
|
||||
busState = "personal bus is not running"
|
||||
} else {
|
||||
|
||||
@@ -61,6 +61,13 @@ func TestPersonalOAEventListAndSchemaCommands(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: personal.EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -161,6 +168,7 @@ func TestPersonalOAEventConsumeDryRunAndValidation(t *testing.T) {
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -414,6 +422,7 @@ func TestPersonalOAMultiConsumeCreatesIndependentAllSubscriptionsOnSharedBus(t *
|
||||
personal.EventOAApprovalTaskFinished,
|
||||
personal.EventOAApprovalTaskRedirected,
|
||||
personal.EventOAApprovalInstanceStarted,
|
||||
personal.EventOAApprovalInstanceCC,
|
||||
personal.EventOAApprovalInstanceTerminated,
|
||||
personal.EventOAApprovalInstanceFinished,
|
||||
}
|
||||
|
||||
@@ -51,7 +51,40 @@ func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool strin
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
return `{"success":true,"result":{"events":[],"hasMore":false,"nextCursor":""}}`
|
||||
case "get_calendar_detail":
|
||||
return c.paramAliasCalendarDetailResponse()
|
||||
case "get_calendar_participants":
|
||||
return `{"success":true,"result":{"participants":[{"userId":"fixture-user","displayName":"Fixture User"},{"userId":"user-2","displayName":"User Two"}]}}`
|
||||
case "search_calendar":
|
||||
return `{"success":true,"result":{"calendars":[]}}`
|
||||
case "search_rooms":
|
||||
return `{"success":true,"result":{"rooms":[]}}`
|
||||
case "query_available_meeting_room":
|
||||
return `{"success":true,"result":{"rooms":[],"hasMore":false}}`
|
||||
case "list_meeting_room_groups":
|
||||
return `{"success":true,"result":{"groups":[]}}`
|
||||
case "query_busy_status":
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
return `{"success":true}`
|
||||
case "respond":
|
||||
status := "accepted"
|
||||
if call := c.lastParamAliasCall(); call != nil {
|
||||
if value, ok := call.args["responseStatus"].(string); ok && value != "" {
|
||||
status = value
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": map[string]any{"responseStatus": status}})
|
||||
return string(encoded)
|
||||
case "get_current_user_profile":
|
||||
return `{"success":true,"result":{"userId":"user-1","name":"Fixture Current User"}}`
|
||||
case "query_records":
|
||||
return `{"success":true,"status":"success","error":{},"data":{}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
@@ -63,9 +96,11 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
return `{"revertedToVersion":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "list_workflows":
|
||||
return `{"workflows":[]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "list_files":
|
||||
@@ -123,6 +158,41 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
}
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) lastParamAliasCall() *paramAliasToolCall {
|
||||
if len(c.calls) == 0 {
|
||||
return nil
|
||||
}
|
||||
return &c.calls[len(c.calls)-1]
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) paramAliasCalendarDetailResponse() string {
|
||||
event := map[string]any{
|
||||
"eventId": "event-1",
|
||||
"summary": "Fixture Meeting",
|
||||
"description": "fixture description",
|
||||
"startDateTime": "2026-03-10T09:00:00+08:00",
|
||||
"endDateTime": "2026-03-10T10:00:00+08:00",
|
||||
}
|
||||
for _, call := range c.calls {
|
||||
switch call.tool {
|
||||
case "create_calendar_event", "update_calendar_event":
|
||||
for _, key := range []string{"eventId", "summary", "description", "startDateTime", "endDateTime", "timeZone", "location", "freeBusy"} {
|
||||
if value, ok := call.args[key]; ok {
|
||||
event[key] = value
|
||||
}
|
||||
}
|
||||
case "respond":
|
||||
if value, ok := call.args["responseStatus"]; ok {
|
||||
event["responseStatus"] = value
|
||||
}
|
||||
case "delete_calendar_event":
|
||||
event["status"] = "cancelled"
|
||||
}
|
||||
}
|
||||
encoded, _ := json.Marshal(map[string]any{"success": true, "result": event})
|
||||
return string(encoded)
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
|
||||
@@ -5,6 +5,8 @@ package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -17,6 +19,8 @@ import (
|
||||
const (
|
||||
appFixtureCurrentDOpenID = "DAAAAAAAAAAAiE"
|
||||
appFixtureCurrentDOpenID2 = "DAQEBAQEBAQEiE"
|
||||
|
||||
paramAliasCalendarPayloadChildEnv = "DWS_TEST_CALENDAR_PARAM_ALIAS_PAYLOAD_CHILD"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
@@ -27,15 +31,56 @@ const (
|
||||
// its spelling while holding every other input constant.
|
||||
var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
|
||||
"aitable +export-data": {"aitable", "+export-data", "--base-id", "base-1", "--scope", "all", "--format", "excel"},
|
||||
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
|
||||
"aitable +find-record": {"aitable", "+find-record", "--base", "base-1", "--table", "table-1", "--query", "fixture"},
|
||||
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
|
||||
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
|
||||
"aitable +record-share-links": {"aitable", "+record-share-links", "--base", "base-1", "--table", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
|
||||
"aitable +workflow-list": {"aitable", "+workflow-list", "--base-id", "base-1", "--limit", "7"},
|
||||
"aitable attachment upload": {"aitable", "attachment", "upload", "--base-id", "base-1", "--file-name", "fixture.txt", "--size", "7"},
|
||||
"aitable base list": {"aitable", "base", "list", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"aitable base update": {"aitable", "base", "update", "--base-id", "base-1", "--name", "Fixture Base", "--desc", "fixture description"},
|
||||
"aitable field search-options": {"aitable", "field", "search-options", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--keyword", "fixture", "--limit", "7"},
|
||||
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
|
||||
"aitable workflow get": {"aitable", "workflow", "get", "--base-id", "base-1", "--workflow-id", "workflow-1"},
|
||||
"aitable workflow history": {"aitable", "workflow", "history", "--base-id", "base-1", "--workflow-id", "workflow-1", "--after-time", "1000", "--before-time", "2000", "--page", "2", "--size", "25"},
|
||||
"aitable workflow run": {"aitable", "workflow", "run", "--base-id", "base-1", "--workflow-id", "workflow-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar +agenda": {"calendar", "+agenda", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +attendee-list": {"calendar", "+attendee-list", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +book": {"calendar", "+book", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--with", "Fixture User", "--yes"},
|
||||
"calendar +book-search": {"calendar", "+book-search", "--query", "fixture"},
|
||||
"calendar +cancel-event": {"calendar", "+cancel-event", "--event", "event-1", "--yes"},
|
||||
"calendar +conflicts": {"calendar", "+conflicts", "--in-days", "1"},
|
||||
"calendar +create": {"calendar", "+create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--desc", "fixture description", "--attendees", "user-1,user-2", "--rooms", "room-1,room-2", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +free": {"calendar", "+free", "--who", "Fixture User", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +free-slots": {"calendar", "+free-slots", "--from", "9", "--to", "18", "--in-days", "1"},
|
||||
"calendar +freebusy": {"calendar", "+freebusy", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +get": {"calendar", "+get", "--event", "event-1", "--calendar-id", "primary"},
|
||||
"calendar +invite": {"calendar", "+invite", "--event", "event-1", "--with", "Fixture User", "--yes"},
|
||||
"calendar +my-free": {"calendar", "+my-free", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +reschedule": {"calendar", "+reschedule", "--event", "event-1", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--yes"},
|
||||
"calendar +room-find": {"calendar", "+room-find", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--room-name", "Fixture Room", "--group-id", "group-1", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-groups": {"calendar", "+room-groups", "--page", "1", "--limit", "7"},
|
||||
"calendar +room-search": {"calendar", "+room-search", "--room-name", "Fixture Room"},
|
||||
"calendar +rsvp": {"calendar", "+rsvp", "--event", "event-1", "--status", "accept", "--calendar-id", "primary", "--yes"},
|
||||
"calendar +search-event": {"calendar", "+search-event", "--query", "fixture", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar +suggest-time": {"calendar", "+suggest-time", "--with", "Fixture User", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar +suggestion": {"calendar", "+suggestion", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar +update": {"calendar", "+update", "--event", "event-1", "--title", "Fixture Updated Meeting", "--desc", "fixture updated description", "--start", "2026-03-10T10:00:00+08:00", "--end", "2026-03-10T11:00:00+08:00", "--add-attendees", "user-2", "--remove-attendees", "user-1", "--yes"},
|
||||
"calendar busy search": {"calendar", "busy", "search", "--users", "user-1,user-2", "--rooms", "room-1,room-2", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00"},
|
||||
"calendar event create": {"calendar", "event", "create", "--title", "Fixture Meeting", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T10:00:00+08:00", "--remind-minutes", "15", "--timezone", "Asia/Shanghai", "--rooms", "room-1,room-2"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"calendar event respond": {"calendar", "event", "respond", "--id", "event-1", "--status", "accepted"},
|
||||
"calendar event suggest": {"calendar", "event", "suggest", "--users", "user-1,user-2", "--duration", "30", "--start", "2026-03-10T09:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--timezone", "Asia/Shanghai"},
|
||||
"calendar event update": {"calendar", "event", "update", "--id", "event-1", "--timezone", "Asia/Shanghai"},
|
||||
"calendar room add": {"calendar", "room", "add", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room delete": {"calendar", "room", "delete", "--event", "event-1", "--rooms", "room-1,room-2"},
|
||||
"calendar room search": {"calendar", "room", "search", "--room-name", "Fixture Room", "--group-id", "group-1", "--start", "2027-03-10T09:00:00+08:00", "--end", "2027-03-10T10:00:00+08:00", "--page", "1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
@@ -62,12 +107,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--content", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
@@ -93,11 +138,11 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--text", "hello fixture", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--group", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", appFixtureCurrentDOpenID, "--content", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send": {"chat", "message", "send", "--user", appFixtureCurrentDOpenID, "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--content", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
@@ -116,7 +161,7 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--content", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
@@ -132,8 +177,8 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc +export": {"doc", "+export", "--node", "node-1", "--export-format", "docx", "--output", "exports/fixture.docx"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--content", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
@@ -196,7 +241,7 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--content", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"doc +inspect": {
|
||||
"include-permissions": {"doc", "+inspect", "--node", "node-1", "--include-permissions"},
|
||||
@@ -219,8 +264,8 @@ var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", appFixtureCurrentDOpenID, "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--content", "hello fixture", "--idempotency-key", "param-alias-equivalence-group", "--yes"},
|
||||
"file": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--idempotency-key", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
@@ -260,7 +305,6 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
|
||||
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
@@ -278,7 +322,7 @@ var paramAliasNewIMCases = []struct {
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
@@ -372,16 +416,113 @@ var paramAliasNewDriveCases = []struct {
|
||||
{command: "drive +upload", emitted: "file-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasNewDriveConfirmationCases selects one newly reviewed alias for
|
||||
// every Drive command in the expansion whose declared runtime safety requires
|
||||
// confirmation. The full matrix below proves all spellings preserve the
|
||||
// confirmed payload; this smaller matrix proves aliases cannot cross the
|
||||
// confirmation boundary before any transport call is made.
|
||||
var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// paramAliasAITableDeleteDisableCompleteCommands contains complete invocations
|
||||
// for every AITable delete/disable command whose confirmation boundary is
|
||||
// reached by aliases introduced in the AITable expansion. These templates are
|
||||
// intentionally separate from paramAliasCompleteCommands: that map mirrors
|
||||
// the reviewed validation fixture one-for-one, while this matrix exhaustively
|
||||
// proves the safety boundary for generated aliases beyond the fixture sample.
|
||||
var paramAliasAITableDeleteDisableCompleteCommands = map[string][]string{
|
||||
"aitable +advperm-disable": {"aitable", "+advperm-disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable +base-delete": {"aitable", "+base-delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable +chart-delete": {"aitable", "+chart-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable +dashboard-delete": {"aitable", "+dashboard-delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable +field-delete": {"aitable", "+field-delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable +form-delete": {"aitable", "+form-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +record-delete": {"aitable", "+record-delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable +role-delete": {"aitable", "+role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable +section-delete": {"aitable", "+section-delete", "--base-id", "base-1", "--section-id", "section-1", "--yes"},
|
||||
"aitable +table-delete": {"aitable", "+table-delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable +view-delete": {"aitable", "+view-delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable +workflow-disable": {"aitable", "+workflow-disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
"aitable advperm disable": {"aitable", "advperm", "disable", "--base-id", "base-1", "--yes"},
|
||||
"aitable advperm role-delete": {"aitable", "advperm", "role-delete", "--base-id", "base-1", "--role-id", "role-1", "--yes"},
|
||||
"aitable base delete": {"aitable", "base", "delete", "--base-id", "base-1", "--yes"},
|
||||
"aitable chart delete": {"aitable", "chart", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--chart-id", "chart-1", "--yes"},
|
||||
"aitable dashboard delete": {"aitable", "dashboard", "delete", "--base-id", "base-1", "--dashboard-id", "dashboard-1", "--yes"},
|
||||
"aitable field delete": {"aitable", "field", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable form delete": {"aitable", "form", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable form questions delete": {"aitable", "form", "questions", "delete", "--base-id", "base-1", "--table-id", "table-1", "--field-id", "field-1", "--yes"},
|
||||
"aitable record delete": {"aitable", "record", "delete", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1", "--yes"},
|
||||
"aitable table delete": {"aitable", "table", "delete", "--base-id", "base-1", "--table-id", "table-1", "--yes"},
|
||||
"aitable view delete": {"aitable", "view", "delete", "--base-id", "base-1", "--table-id", "table-1", "--view-id", "view-1", "--yes"},
|
||||
"aitable workflow disable": {"aitable", "workflow", "disable", "--base-id", "base-1", "--workflow-id", "workflow-1", "--yes"},
|
||||
}
|
||||
|
||||
// paramAliasNewAITableDeleteDisableCases is the exhaustive set of alias
|
||||
// tuples newly introduced by this change on AITable delete/disable commands
|
||||
// that require confirmation. Every tuple must remain on both sides of the
|
||||
// confirmation gate: rejected with zero calls before --yes, and exactly
|
||||
// payload-equivalent to its canonical spelling after --yes.
|
||||
var paramAliasNewAITableDeleteDisableCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable +advperm-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +advperm-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +base-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +chart-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +dashboard-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +field-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +form-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +form-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +record-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +record-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +role-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +section-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +table-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +view-delete", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +view-delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable +workflow-disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable advperm disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable advperm role-delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable base delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable chart delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable dashboard delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable field delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable form questions delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable form questions delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable record delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable record delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable table delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable table delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable view delete", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable view delete", emitted: "table", canonical: "table-id"},
|
||||
{command: "aitable workflow disable", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow disable", emitted: "flow-id", canonical: "workflow-id"},
|
||||
}
|
||||
|
||||
// paramAliasNewConfirmationCases selects newly reviewed aliases for commands
|
||||
// whose declared runtime safety requires confirmation. The full matrix below
|
||||
// proves all spellings preserve the confirmed payload; this smaller matrix
|
||||
// proves aliases cannot cross the confirmation boundary before any transport
|
||||
// call is made.
|
||||
var paramAliasNewConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "aitable workflow run", emitted: "base-token", canonical: "base-id"},
|
||||
{command: "aitable workflow run", emitted: "flow-id", canonical: "workflow-id"},
|
||||
{command: "aitable workflow run", emitted: "table", canonical: "table-id"},
|
||||
{command: "drive +delete", emitted: "file-id", canonical: "node"},
|
||||
{command: "drive +publish-unset", emitted: "document-url", canonical: "node"},
|
||||
{command: "drive +recycle-restore", emitted: "recycle-item-id", canonical: "id"},
|
||||
@@ -402,7 +543,27 @@ var paramAliasNewDriveConfirmationCases = []struct {
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +export-data", "export-format"): true, // shortcut-local export format keeps the final payload
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "base-id"): true, // shortcut Base ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +find-record", "table-id"): true, // shortcut Table ID compatibility
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "base-id"): true, // observed experiment Base ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +record-share-links", "table-id"): true, // observed experiment Table ID spelling
|
||||
paramAliasPayloadCaseKey("aitable +workflow-list", "max-results"): true, // shortcut pagination-size alias
|
||||
paramAliasPayloadCaseKey("aitable attachment upload", "file-size"): true, // byte-size command override
|
||||
paramAliasPayloadCaseKey("aitable base list", "next-cursor"): true, // cursor concept alias
|
||||
paramAliasPayloadCaseKey("aitable base update", "description"): true, // plain description alias on a write command
|
||||
paramAliasPayloadCaseKey("aitable field search-options", "query"): true, // search keyword concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow get", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "base-token"): true, // Base ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "end-time"): true, // upper time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "flow-id"): true, // workflow ID concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-index"): true, // zero-based page override
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "page-size"): true, // page-size concept alias
|
||||
paramAliasPayloadCaseKey("aitable workflow history", "start-time"): true, // lower time-bound override
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "base-token"): true, // Base ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "flow-id"): true, // workflow ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("aitable workflow run", "table"): true, // Table ID concept alias on a confirmed write
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
@@ -426,7 +587,11 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("chat +messages-reply", "conversation-id"): true, // renamed conversation Primary keeps final reply payload
|
||||
paramAliasPayloadCaseKey("chat message send", "file-path"): true, // renamed local-file Primary reaches the same final payload
|
||||
paramAliasPayloadCaseKey("doc +doc-append", "text"): true, // shortcut content rename keeps append payload
|
||||
paramAliasPayloadCaseKey("doc block insert", "text"): true, // block write content compatibility alias
|
||||
paramAliasPayloadCaseKey("doc block update", "text"): true, // update uses the same typed compatibility path
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
@@ -435,6 +600,108 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
// compares the final captured transport calls; the owning top-level test runs
|
||||
// these allocations in a short-lived race-instrumented subprocess so all Root
|
||||
// registrations are released together when that process exits.
|
||||
var paramAliasCalendarPayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +agenda", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +attendee-list", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "search"): true,
|
||||
paramAliasPayloadCaseKey("calendar +book-search", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free", "name"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "start-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "end-hour"): true,
|
||||
paramAliasPayloadCaseKey("calendar +free-slots", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +freebusy", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +my-free", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "participant-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-groups", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-search", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggest-time", "attendee-names"): true,
|
||||
paramAliasPayloadCaseKey("calendar +conflicts", "day-offset"): true,
|
||||
paramAliasPayloadCaseKey("calendar busy search", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "reminder-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar event create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar event respond", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar event suggest", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar event update", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar room add", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room delete", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar room search", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "room-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +get", "calendar-book-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-size"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "page-index"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "room-group-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +room-find", "query"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "keyword"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "next-cursor"): true,
|
||||
paramAliasPayloadCaseKey("calendar +search-event", "max-results"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "duration-minutes"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "to"): true,
|
||||
paramAliasPayloadCaseKey("calendar +suggestion", "tz"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "description"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "add-user-ids"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "remove-user-ids"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarConfirmationCases selects one newly reviewed alias for
|
||||
// every Calendar Shortcut whose runtime contract requires user confirmation.
|
||||
// The complete Calendar matrix proves confirmed canonical/alias payload
|
||||
// equality; these representatives additionally prove semantic normalization
|
||||
// cannot cross the confirmation boundary before the first transport call.
|
||||
var paramAliasCalendarConfirmationCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("calendar +book", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +cancel-event", "event-id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +create", "summary"): true,
|
||||
paramAliasPayloadCaseKey("calendar +invite", "id"): true,
|
||||
paramAliasPayloadCaseKey("calendar +reschedule", "from"): true,
|
||||
paramAliasPayloadCaseKey("calendar +rsvp", "response-status"): true,
|
||||
paramAliasPayloadCaseKey("calendar +update", "event-id"): true,
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
@@ -468,28 +735,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -519,6 +765,118 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
if os.Getenv(paramAliasCalendarPayloadChildEnv) != "1" {
|
||||
command := exec.Command(
|
||||
os.Args[0],
|
||||
"-test.run=^TestCrossPlatformCoverageReviewedCalendarParamAliasesReachCanonicalEquivalentFinalPayloads$",
|
||||
"-test.count=1",
|
||||
"-test.timeout=5m",
|
||||
)
|
||||
command.Env = append(os.Environ(), paramAliasCalendarPayloadChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("Calendar param-alias payload subprocess failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
executed := make(map[string]bool)
|
||||
executedConfirmation := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasCalendarPayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executed[caseKey] = true
|
||||
fixture := fixture
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Calendar alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete Calendar command must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Expect, replacements, canonicalArgs)
|
||||
}
|
||||
assertParamAliasFinalPayloadEquivalent(t, fixture.Command, canonicalArgs, aliasArgs)
|
||||
if paramAliasCalendarConfirmationCases[caseKey] {
|
||||
executedConfirmation[caseKey] = true
|
||||
assertParamAliasCannotBypassConfirmation(t, aliasArgs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for caseKey := range paramAliasCalendarPayloadCases {
|
||||
if !executed[caseKey] {
|
||||
t.Errorf("Calendar final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executed) != len(paramAliasCalendarPayloadCases) {
|
||||
t.Fatalf("Calendar final-payload coverage = %d, want %d", len(executed), len(paramAliasCalendarPayloadCases))
|
||||
}
|
||||
for caseKey := range paramAliasCalendarConfirmationCases {
|
||||
if !executedConfirmation[caseKey] {
|
||||
t.Errorf("Calendar confirmation case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedConfirmation) != len(paramAliasCalendarConfirmationCases) {
|
||||
t.Fatalf("Calendar confirmation coverage = %d, want %d", len(executedConfirmation), len(paramAliasCalendarConfirmationCases))
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed Calendar alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed Calendar alias command error = %#v, want confirmation_required\nargs=%v", err, unconfirmedArgs)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed Calendar alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
@@ -629,13 +987,116 @@ func TestCrossPlatformCoverageNewDriveParamAliasesReachCanonicalEquivalentFinalP
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewDriveConfirmationCases {
|
||||
func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmationAndPayload(t *testing.T) {
|
||||
coveredCommands := make(map[string]bool)
|
||||
reviewedAliases := make(map[string]string, len(paramAliasNewAITableDeleteDisableCases))
|
||||
for _, test := range paramAliasNewAITableDeleteDisableCases {
|
||||
test := test
|
||||
caseKey := paramAliasPayloadCaseKey(test.command, test.emitted)
|
||||
if previous, duplicate := reviewedAliases[caseKey]; duplicate {
|
||||
t.Fatalf("duplicate AITable delete/disable alias case %q: --%s and --%s", caseKey, previous, test.canonical)
|
||||
}
|
||||
reviewedAliases[caseKey] = test.canonical
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasAITableDeleteDisableCompleteCommands[test.command]
|
||||
if !ok {
|
||||
t.Fatal("reviewed AITable delete/disable alias has no complete safety template")
|
||||
}
|
||||
coveredCommands[test.command] = true
|
||||
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, "--yes")
|
||||
if removals != 1 {
|
||||
t.Fatalf("safety template must contain --yes exactly once; removals=%d args=%v", removals, aliasArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed AITable alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
unconfirmedCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, unconfirmedErr := executeParamAliasPayloadE2E(t, unconfirmedCaller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(unconfirmedErr, &appErr) || appErr.Reason != "confirmation_required" {
|
||||
t.Fatalf("unconfirmed AITable alias command error = %#v, want confirmation_required\nargs=%v", unconfirmedErr, unconfirmedArgs)
|
||||
}
|
||||
if len(unconfirmedCaller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed AITable alias crossed the transport boundary: args=%v calls=%#v", unconfirmedArgs, unconfirmedCaller.calls)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("confirmed canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("confirmed canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
aliasCtx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("confirmed alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if aliasCtx == nil {
|
||||
t.Fatal("confirmed AITable alias command skipped PreParse")
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("confirmed final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
if !coveredCommands[command] {
|
||||
t.Errorf("AITable delete/disable safety template %q has no reviewed alias case", command)
|
||||
}
|
||||
}
|
||||
if len(coveredCommands) != len(paramAliasAITableDeleteDisableCompleteCommands) {
|
||||
t.Fatalf("AITable delete/disable safety coverage = %d commands, want %d", len(coveredCommands), len(paramAliasAITableDeleteDisableCompleteCommands))
|
||||
}
|
||||
|
||||
activeAliases := 0
|
||||
for command := range paramAliasAITableDeleteDisableCompleteCommands {
|
||||
entry, exists := cli.LookupParamAlias(command)
|
||||
if !exists {
|
||||
t.Errorf("AITable delete/disable safety command %q has no generated alias entry", command)
|
||||
continue
|
||||
}
|
||||
for emitted, canonical := range entry.Aliases {
|
||||
activeAliases++
|
||||
caseKey := paramAliasPayloadCaseKey(command, emitted)
|
||||
reviewedCanonical, reviewed := reviewedAliases[caseKey]
|
||||
if !reviewed {
|
||||
t.Errorf("active AITable delete/disable alias %q --%s -> --%s has no confirmation/payload case", command, emitted, canonical)
|
||||
continue
|
||||
}
|
||||
if reviewedCanonical != canonical {
|
||||
t.Errorf("reviewed AITable delete/disable alias %q --%s target = --%s, generated --%s", command, emitted, reviewedCanonical, canonical)
|
||||
}
|
||||
}
|
||||
}
|
||||
if activeAliases != len(reviewedAliases) {
|
||||
t.Fatalf("AITable delete/disable generated alias coverage = %d, want %d reviewed cases", activeAliases, len(reviewedAliases))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed Drive confirmation alias has no complete-command E2E template")
|
||||
t.Fatal("reviewed confirmation alias has no complete-command E2E template")
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
@@ -649,7 +1110,7 @@ func TestCrossPlatformCoverageNewDriveParamAliasesCannotBypassConfirmation(t *te
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active || target != test.canonical {
|
||||
t.Fatalf("reviewed Drive alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
t.Fatalf("reviewed confirmation alias --%s resolution = exists:%v active:%v target:%q, want --%s", test.emitted, exists, active, target, test.canonical)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
|
||||
@@ -264,7 +264,7 @@ func TestRootChatMediaUploadWithoutAppCredentialsReturnsMigrationValidation(t *t
|
||||
}
|
||||
|
||||
got := output.String() + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat media upload migration output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -439,7 +439,7 @@ func TestChatFileUploadDownlinedButMessageFileSendStays(t *testing.T) {
|
||||
t.Fatalf("chat file upload error = nil, want downline error\n%s", got)
|
||||
}
|
||||
got = got + "\n" + err.Error()
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file-path"} {
|
||||
for _, want := range []string{"已下线", "upload_conversation_file_by_url", "chat message send --msg-type file --file"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("chat file upload output missing %q:\n%s", want, got)
|
||||
}
|
||||
@@ -462,6 +462,53 @@ func TestCalendarEventListDryRunPreviewsOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoDryRunPreviewsOnly(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
"--language", "zh-CN",
|
||||
"--calendar-id", "primary",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run error = %v\n%s", err, got)
|
||||
}
|
||||
for _, want := range []string{"get_event_share_info", "eventId", "EVT_001", "zh-CN", "primary"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("calendar event share-info dry-run output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoRequiresEventID(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatalf("calendar event share-info without --id: expected error, got nil\n%s", got)
|
||||
}
|
||||
if strings.Contains(got, "\"executed\": true") {
|
||||
t.Fatalf("share-info without --id must not execute:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalendarEventShareInfoOmitsOptionalArgs(t *testing.T) {
|
||||
got, err := executeRootCaptureStdout(t, []string{
|
||||
"--dry-run", "calendar", "event", "share-info",
|
||||
"--id", "EVT_001",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("calendar event share-info --dry-run with only --id error = %v\n%s", err, got)
|
||||
}
|
||||
if !strings.Contains(got, "\"eventId\"") {
|
||||
t.Fatalf("calendar event share-info dry-run output missing eventId:\n%s", got)
|
||||
}
|
||||
for _, unwanted := range []string{"\"calendarId\"", "\"language\""} {
|
||||
if strings.Contains(got, unwanted) {
|
||||
t.Fatalf("calendar event share-info dry-run with only --id should not contain %q:\n%s", unwanted, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootKeepsSVIPChatCompatibilityFlags(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// 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.
|
||||
|
||||
package app
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageCalendarAgendaFinalSchemaPreservesCompositeProperties(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["calendar.shortcut_agenda"]
|
||||
if tool == nil {
|
||||
t.Fatal("calendar.shortcut_agenda is missing from final Schema")
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for flag, want := range map[string]string{
|
||||
"start": "start",
|
||||
"end": "end",
|
||||
} {
|
||||
parameter := parameters[flag]
|
||||
if parameter == nil {
|
||||
t.Fatalf("calendar.shortcut_agenda --%s is missing from final Schema", flag)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Errorf("calendar.shortcut_agenda --%s property=%q, want %q", flag, got, want)
|
||||
}
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("calendar.shortcut_agenda interface_mode=%q, want composite", got)
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
for _, field := range []string{"hasMore", "nextCursor"} {
|
||||
if _, exists := properties[field]; exists {
|
||||
t.Fatalf("calendar.shortcut_agenda Result data_schema leaked pagination field %q", field)
|
||||
}
|
||||
}
|
||||
if properties["complete"] == nil {
|
||||
t.Fatal("calendar.shortcut_agenda Result data_schema is missing complete")
|
||||
}
|
||||
pagination, ok := tool["pagination"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination=%T, want object", tool["pagination"])
|
||||
}
|
||||
if got := schemaContractString(pagination["meta_path"]); got != "meta.pagination" {
|
||||
t.Fatalf("calendar.shortcut_agenda pagination meta_path=%q, want meta.pagination", got)
|
||||
}
|
||||
}
|
||||
@@ -400,6 +400,7 @@ func schemaContractPayloadForBoundCanonicals(t *testing.T, root *cobra.Command,
|
||||
func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
snapshot := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"chat.send_personal_message",
|
||||
"chat.send_robot_message",
|
||||
"chat.reply_personal_message",
|
||||
)
|
||||
|
||||
@@ -418,6 +419,19 @@ func TestChatSchemaSeparatesSendAndReply(t *testing.T) {
|
||||
if _, exists := snapshot.Tools["chat.upload_conversation_file"]; exists {
|
||||
t.Fatal("downlined chat file upload must not be advertised in Schema")
|
||||
}
|
||||
|
||||
botReply := snapshot.Tools["chat.send_robot_message"]
|
||||
botParams := schemaContractMap(botReply["parameters"])
|
||||
if got := schemaContractString(botParams["reply"]["property"]); got != "referenceOpenMessageId" {
|
||||
t.Fatalf("bot --reply property = %q", got)
|
||||
}
|
||||
if got := schemaContractString(botParams["ref-sender"]["property"]); got != "srcMsgSendOpenDingTalkId" {
|
||||
t.Fatalf("bot --ref-sender property = %q", got)
|
||||
}
|
||||
if got, ok := botParams["title"]["required"].(bool); !ok || got {
|
||||
t.Fatalf("bot --title required = %#v, want false for conditional Markdown input", botParams["title"]["required"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, botReply, "require_together", []string{"reply", "ref-sender"})
|
||||
}
|
||||
|
||||
func TestCalendarAttendeeDeleteSchemaMatchesRuntimeGate(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t *testing.T) {
|
||||
tests := []struct {
|
||||
cliPath string
|
||||
canonical string
|
||||
rpc string
|
||||
description string
|
||||
effect string
|
||||
resultType string
|
||||
resultFields map[string]string
|
||||
sensitivePaths []string
|
||||
}{
|
||||
{
|
||||
cliPath: "oa approval attachment download-url",
|
||||
canonical: "oa.get_attachment_download_url",
|
||||
rpc: "get_attachment_download_url",
|
||||
description: "获取审批附件下载授权并生成临时下载链接",
|
||||
effect: "read",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "downloadUri": "string",
|
||||
"class": "string", "fileId": "string",
|
||||
},
|
||||
sensitivePaths: []string{"downloadUri"},
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-download",
|
||||
canonical: "oa.auth_download_file",
|
||||
rpc: "auth_download_file",
|
||||
description: "批量授权当前用户下载指定的审批钉盘文件",
|
||||
effect: "write",
|
||||
resultType: "boolean",
|
||||
},
|
||||
{
|
||||
cliPath: "oa approval attachment authorize-preview",
|
||||
canonical: "oa.auth_preview_attachment",
|
||||
rpc: "auth_preview_attachment",
|
||||
description: "批量授权当前用户预览审批单中的附件",
|
||||
effect: "write",
|
||||
resultType: "object",
|
||||
resultFields: map[string]string{
|
||||
"spaceId": "integer", "agentId": "integer", "class": "string",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.rpc, func(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
command := exactCommandForTest(root, test.cliPath)
|
||||
if command == nil {
|
||||
t.Fatalf("executable command %q is missing", test.cliPath)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
root.SetOut(&stdout)
|
||||
root.SetErr(&stderr)
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
|
||||
var tool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &tool); err != nil {
|
||||
t.Fatalf("decode delivery schema leaf: %v", err)
|
||||
}
|
||||
if got := schemaContractString(tool["canonical_path"]); got != test.canonical {
|
||||
t.Fatalf("canonical_path = %q, want %q", got, test.canonical)
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != test.cliPath {
|
||||
t.Fatalf("primary_cli_path = %q, want %q", got, test.cliPath)
|
||||
}
|
||||
if got := schemaContractString(tool["description"]); !strings.HasPrefix(got, test.description) {
|
||||
t.Fatalf("description = %q, want prefix %q", got, test.description)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "mcp" {
|
||||
t.Fatalf("interface_mode = %q, want mcp", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
interfaceRef := schemaInterfaceObject(tool["interface_ref"])
|
||||
if got := schemaContractString(interfaceRef["product_id"]); got != "oa" {
|
||||
t.Fatalf("interface_ref.product_id = %q, want oa", got)
|
||||
}
|
||||
if got := schemaContractString(interfaceRef["rpc_name"]); got != test.rpc {
|
||||
t.Fatalf("interface_ref.rpc_name = %q, want %q", got, test.rpc)
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != test.effect {
|
||||
t.Fatalf("effect = %q, want %q", got, test.effect)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
if got := schemaContractString(tool["idempotency"]); got != "idempotent" {
|
||||
t.Fatalf("idempotency = %q, want idempotent", got)
|
||||
}
|
||||
fullResult := oaAttachmentResultContract(t, tool, test.resultType, test.resultFields, test.sensitivePaths)
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
root.SetArgs([]string{"schema", test.cliPath, "--compact", "--format", "json"})
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute compact delivery schema leaf: %v; stderr=%s", err, stderr.String())
|
||||
}
|
||||
var compactTool map[string]any
|
||||
if err := json.Unmarshal(stdout.Bytes(), &compactTool); err != nil {
|
||||
t.Fatalf("decode compact delivery schema leaf: %v", err)
|
||||
}
|
||||
compactResult, ok := compactTool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("compact result = %#v, want object", compactTool["result"])
|
||||
}
|
||||
if !reflect.DeepEqual(compactResult, fullResult) {
|
||||
t.Fatalf("compact/full result projection differs\ncompact: %#v\nfull: %#v", compactResult, fullResult)
|
||||
}
|
||||
if problem := schemaHelpFlagCompletenessProblem(test.canonical, test.cliPath, command, tool); problem != "" {
|
||||
t.Fatal(problem)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("full result = %#v, want object", tool["result"])
|
||||
}
|
||||
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("result.outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := schemaContractStringSlice(result["sensitive_paths"]); !reflect.DeepEqual(got, sensitivePaths) {
|
||||
t.Fatalf("result.sensitive_paths = %#v, want %#v", got, sensitivePaths)
|
||||
}
|
||||
dataSchema, ok := result["data_schema"].(map[string]any)
|
||||
if !ok || schemaContractString(dataSchema["type"]) != resultType {
|
||||
t.Fatalf("result.data_schema = %#v, want type %q", result["data_schema"], resultType)
|
||||
}
|
||||
properties, _ := dataSchema["properties"].(map[string]any)
|
||||
if len(properties) != len(fields) {
|
||||
t.Fatalf("result.data_schema.properties = %#v, want fields %#v", properties, fields)
|
||||
}
|
||||
for name, fieldType := range fields {
|
||||
property, ok := properties[name].(map[string]any)
|
||||
if !ok || schemaContractString(property["type"]) != fieldType {
|
||||
t.Fatalf("result.data_schema.properties.%s = %#v, want type %q", name, properties[name], fieldType)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 418
|
||||
publicShortcutCount = 435
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 420
|
||||
schemaPublishedShortcutCount = 438
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 418
|
||||
publiclyDeliveredShortcutCount = 435
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -140,6 +140,25 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
if got, want := schemaContractStringSlice(result["outcomes"]), []string{"success", "failure"}; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("aitable +table-bootstrap outcomes = %#v, want %#v", got, want)
|
||||
}
|
||||
dataSchema, _ := result["data_schema"].(map[string]any)
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
status := properties["status"]
|
||||
if got, want := schemaContractStringSlice(status["enum"]), []string{"success", "planned", "partial_success", "unknown"}; !schemaContractJSONEqual(got, want) {
|
||||
t.Fatalf("aitable +table-bootstrap status enum = %#v, want %#v", got, want)
|
||||
}
|
||||
for _, property := range []string{"contractVersion", "operation", "executed", "retryable", "plan", "completedSteps", "verification", "checkpoint", "knownSideEffects", "result"} {
|
||||
if properties[property] == nil {
|
||||
t.Errorf("aitable +table-bootstrap final Result data_schema is missing %q", property)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryWikiSpaceSearchDeclaresCompatibilityAdapter(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "wiki +space-search")
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
|
||||
+128
-26
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("✓"))
|
||||
}
|
||||
|
||||
@@ -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" {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,242 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package cli
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPrimaryParamMigrationKeepsConceptDirectionCommandScoped(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if got := concepts.ByConcept["content_text"].CanonicalHint; got != "text" {
|
||||
t.Fatalf("content_text canonical hint = %q, want unchanged global hint text", got)
|
||||
}
|
||||
if got := concepts.ByConcept["open_conversation_id"].CanonicalHint; got != "conversation-id" {
|
||||
t.Fatalf("open_conversation_id canonical hint = %q, want unchanged global hint conversation-id", got)
|
||||
}
|
||||
|
||||
content := concepts.ByConcept["content_text"]
|
||||
docNode := concepts.ByConcept["doc_node_id"]
|
||||
docAppend, problems := reduceLeafParamAliases(
|
||||
"doc +doc-append",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "doc"},
|
||||
),
|
||||
[]Concept{content, docNode},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc +doc-append reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docAppend, "body", "content")
|
||||
assertPrimaryParamAlias(t, docAppend, "node", "doc")
|
||||
|
||||
insertOverride := primaryParamOverride(t, concepts, "doc block insert")
|
||||
docInsert, problems := reduceLeafParamAliases(
|
||||
"doc block insert",
|
||||
realMap(
|
||||
realFlag{name: "content"},
|
||||
realFlag{name: "text", hidden: true},
|
||||
realFlag{name: "parent-block"},
|
||||
realFlag{name: "ref-block"},
|
||||
),
|
||||
[]Concept{content},
|
||||
insertOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block insert reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docInsert, "body", "content")
|
||||
|
||||
docUpdate, problems := reduceLeafParamAliases(
|
||||
"doc block update",
|
||||
realMap(realFlag{name: "content"}, realFlag{name: "text", hidden: true}),
|
||||
[]Concept{content},
|
||||
CommandOverride{},
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("doc block update reduction problems = %v", problems)
|
||||
}
|
||||
assertPrimaryParamAlias(t, docUpdate, "body", "content")
|
||||
|
||||
replyOverride := primaryParamOverride(t, concepts, "chat +messages-reply")
|
||||
chatReply, problems := reduceLeafParamAliases(
|
||||
"chat +messages-reply",
|
||||
realMap(
|
||||
realFlag{name: "group"},
|
||||
realFlag{name: "conversation-id", hidden: true},
|
||||
realFlag{name: "ref-msg-id"},
|
||||
),
|
||||
[]Concept{concepts.ByConcept["open_conversation_id"]},
|
||||
replyOverride,
|
||||
)
|
||||
if len(problems) != 0 {
|
||||
t.Fatalf("chat +messages-reply reduction problems = %v", problems)
|
||||
}
|
||||
for _, emitted := range []string{"chat", "chat-id", "open-conversation-id"} {
|
||||
assertPrimaryParamAlias(t, chatReply, emitted, "group")
|
||||
}
|
||||
assertPrimaryParamAlias(t, chatReply, "msg-id", "ref-msg-id")
|
||||
if chatReply.IsBlocked("group") {
|
||||
t.Fatal("chat +messages-reply canonical --group must not remain blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationUpdatesReviewedFixturesAndMappingKeys(t *testing.T) {
|
||||
concepts, err := LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
fixtures := []ParamFixtureCase{
|
||||
{Command: "doc +doc-append", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "doc +doc-append", Emitted: "node", Expect: "doc", Via: "concept:doc_node_id"},
|
||||
{Command: "doc block insert", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 2},
|
||||
{Command: "doc block update", Emitted: "text", Expect: "content", Via: "native:reviewed-compatibility-alias", Occ: 6},
|
||||
{Command: "chat message send", Emitted: "file-path", Expect: "file", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "conversation-id", Expect: "group", Via: "native:reviewed-compatibility-alias"},
|
||||
{Command: "chat +messages-reply", Emitted: "chat", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
{Command: "chat +messages-reply", Emitted: "open-conversation-id", Expect: "group", Via: "concept:open_conversation_id"},
|
||||
}
|
||||
for _, want := range fixtures {
|
||||
found := false
|
||||
for _, got := range concepts.Fixture {
|
||||
if got == want {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("reviewed fixture missing %#v", want)
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
oldKey string
|
||||
newKey string
|
||||
reason string
|
||||
}{
|
||||
{"chat.reply_personal_message --text", "chat.reply_personal_message --content", "serialized into the aggregate content JSON string"},
|
||||
{"chat.send_personal_message --file-path", "chat.send_personal_message --file", "local upload/preprocessing input"},
|
||||
{"chat.send_personal_message --text", "chat.send_personal_message --content", "serialized into the aggregate content payload"},
|
||||
{"doc.insert_document_block --text", "doc.insert_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"doc.update_document_block --text", "doc.update_document_block --content", "aggregate convenience input used to build element"},
|
||||
{"todo.add_todo_attachment --file-path", "todo.add_todo_attachment --file", "local upload input used to construct attachmentList"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
if _, exists := reviewedSchemaParameterMappingExclusions[test.oldKey]; exists {
|
||||
t.Errorf("stale mapping exclusion remains at %q", test.oldKey)
|
||||
}
|
||||
if got := reviewedSchemaParameterMappingExclusions[test.newKey]; got != test.reason {
|
||||
t.Errorf("mapping exclusion %q reason = %q, want %q", test.newKey, got, test.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrimaryParamMigrationPreservesDeliverySchemaSignatures(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
primary string
|
||||
legacy string
|
||||
required bool
|
||||
cliRequired bool
|
||||
property string
|
||||
propertyFrom string
|
||||
format string
|
||||
requiredWhen string
|
||||
requiredWhenFrom string
|
||||
}{
|
||||
{path: "aisearch person", primary: "query", legacy: "keyword", required: true, property: "keyword", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat message reply", primary: "content", legacy: "text", required: true, cliRequired: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "chat message send", primary: "file", legacy: "file-path", propertyFrom: "reviewed_mapping_exclusion", format: "file-path", requiredWhen: "msg-type is file or audio or video", requiredWhenFrom: "typed_parameter_metadata"},
|
||||
{path: "chat message send-by-webhook", primary: "content", legacy: "text", required: true, cliRequired: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +broadcast", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +dm", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +send-to-group", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-batch-send-by-bot", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-send-by-webhook", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "chat +messages-reply", primary: "group", legacy: "conversation-id", required: true, property: "conversationId", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc +doc-append", primary: "content", legacy: "text", required: true, property: "text", propertyFrom: "native_annotation", requiredWhenFrom: "default"},
|
||||
{path: "doc block insert", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "doc block update", primary: "content", legacy: "text", propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
{path: "todo task add-attachment", primary: "file", legacy: "file-path", required: true, propertyFrom: "reviewed_mapping_exclusion", requiredWhenFrom: "default"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path+"/"+test.primary, func(t *testing.T) {
|
||||
leaf, err := queryDeliverySchemaPayload([]string{test.path})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parameters := schemaMap(leaf["parameters"])
|
||||
parameter, exists := parameters[test.primary]
|
||||
if !exists {
|
||||
t.Fatalf("%s missing visible Primary --%s", test.path, test.primary)
|
||||
}
|
||||
if _, exists := parameters[test.legacy]; exists {
|
||||
t.Fatalf("%s unexpectedly publishes hidden legacy --%s", test.path, test.legacy)
|
||||
}
|
||||
if got := parameter["type"]; got != "string" {
|
||||
t.Errorf("%s --%s type = %#v, want string", test.path, test.primary, got)
|
||||
}
|
||||
if got := parameter["required"]; got != test.required {
|
||||
t.Errorf("%s --%s required = %#v, want %v", test.path, test.primary, got, test.required)
|
||||
}
|
||||
if got, _ := parameter["cli_required"].(bool); got != test.cliRequired {
|
||||
t.Errorf("%s --%s cli_required = %#v, want %v", test.path, test.primary, parameter["cli_required"], test.cliRequired)
|
||||
}
|
||||
if got, _ := parameter["property"].(string); got != test.property {
|
||||
t.Errorf("%s --%s property = %q, want %q", test.path, test.primary, got, test.property)
|
||||
}
|
||||
if got := parameter["interface_type"]; got != nil && got != "" {
|
||||
t.Errorf("%s --%s interface_type = %#v, want omitted/empty", test.path, test.primary, got)
|
||||
}
|
||||
if got, _ := parameter["format"].(string); got != test.format {
|
||||
t.Errorf("%s --%s format = %q, want %q", test.path, test.primary, got, test.format)
|
||||
}
|
||||
if got, _ := parameter["required_when"].(string); got != test.requiredWhen {
|
||||
t.Errorf("%s --%s required_when = %q, want %q", test.path, test.primary, got, test.requiredWhen)
|
||||
}
|
||||
|
||||
provenance := schemaMap(parameter["field_provenance"])
|
||||
propertyProvenance := provenance["property"]
|
||||
if got := propertyProvenance["source"]; got != test.propertyFrom {
|
||||
t.Errorf("%s --%s property source = %#v, want %s", test.path, test.primary, got, test.propertyFrom)
|
||||
}
|
||||
typeProvenance := provenance["type"]
|
||||
if got := typeProvenance["source"]; got != "cobra_flag_type" {
|
||||
t.Errorf("%s --%s type source = %#v, want cobra_flag_type", test.path, test.primary, got)
|
||||
}
|
||||
requiredWhenProvenance := provenance["required_when"]
|
||||
if got := requiredWhenProvenance["source"]; got != test.requiredWhenFrom {
|
||||
t.Errorf("%s --%s required_when source = %#v, want %s", test.path, test.primary, got, test.requiredWhenFrom)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func primaryParamOverride(t *testing.T, concepts ParamConcepts, path string) CommandOverride {
|
||||
t.Helper()
|
||||
for _, override := range concepts.Overrides {
|
||||
if override.CommandPath == path {
|
||||
return override
|
||||
}
|
||||
}
|
||||
t.Fatalf("command override %q not found", path)
|
||||
return CommandOverride{}
|
||||
}
|
||||
|
||||
func assertPrimaryParamAlias(t *testing.T, entry *ParamAliasEntry, emitted, want string) {
|
||||
t.Helper()
|
||||
if entry == nil {
|
||||
t.Fatalf("alias entry is nil; want %s -> %s", emitted, want)
|
||||
}
|
||||
if got, ok := entry.ResolveAlias(emitted); !ok || got != want {
|
||||
t.Fatalf("ResolveAlias(%q) = %q (ok=%v), want %q", emitted, got, ok, want)
|
||||
}
|
||||
}
|
||||
@@ -68,9 +68,9 @@ func init() {
|
||||
registerRequireOneOf("devdoc.search_open_platform_docs_rag", "query", "keyword")
|
||||
registerRequireOneOf("event.consume", "event_key", "subscribe-id")
|
||||
registerExclusiveOneOf("event.stop", "all", "subscribe_id")
|
||||
registerRequireOneOf("doc.insert_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.insert_document_block", "content", "heading", "element")
|
||||
registerExclusiveOneOf("doc.update_document", "content", "content-file")
|
||||
registerRequireOneOf("doc.update_document_block", "text", "heading", "element")
|
||||
registerRequireOneOf("doc.update_document_block", "content", "heading", "element")
|
||||
registerRequireOneOf("pat.batch_grant", "scope", "product", "products", "domain", "domains", "recommend")
|
||||
registerRequireOneOf("mail.search_mail_users", "keyword", "employee-no")
|
||||
// --body is a hidden compatibility alias for the public --content flag.
|
||||
|
||||
@@ -269,20 +269,20 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"chat.update_text_emotion --conversation-id": "Reviewed unpinned adapter: chat.update_text_emotion has no singular pinned interface_ref; --conversation-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.reply_personal_message --ref-msg-id": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --ref-sender": "resolved then serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --text": "serialized into the aggregate content JSON string",
|
||||
"chat.reply_personal_message --content": "serialized into the aggregate content JSON string",
|
||||
"chat.search_groups --cursor": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --exclude-muted": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --exclude-muted is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_groups --limit": "Reviewed unpinned adapter: chat.search_groups has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"chat.search_messages --user": "conditional wrapper: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.search_messages --users": "conditional wrapper/alias of --user: parseCSVValues + appendChatIDArgs routes each supplied identifier to senderUserIds or senderOpenDingTakIds according to its runtime ID shape; there is no single RPC property for this flag",
|
||||
"chat.send_personal_message --contact-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --file-path": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --file": "local upload/preprocessing input",
|
||||
"chat.send_personal_message --latitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --location-name": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --longitude": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --map-thumbnail-url": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --media-id": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --text": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --content": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --title": "serialized into the aggregate content payload",
|
||||
"chat.send_personal_message --user": "resolves a userId to receiverOpenDingTalkId when possible and otherwise uses the unpinned receiverUserId compatibility property",
|
||||
"chat.set_group_member_mute_list --user": "conditional wrapper/alias of --users: userIds are remotely resolved to openDingTalkIds outside dry-run and otherwise may be sent as uids; openDingTalkId inputs are sent as openDingTalkIds, so one exact property would misdescribe execution",
|
||||
@@ -406,7 +406,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.insert_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.insert_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.list_document_blocks --block-id": "runtime extension sends blockId, which is absent from the pinned list_document_blocks metadata",
|
||||
"doc.reply_comment --mentioned-open-conversation-id": "Runtime extension sends mentionedOpenConversationIds, which is absent from the immutable pinned reply_comment metadata at its declared source revision.",
|
||||
"doc.style_background_clear --node": "Reviewed unpinned adapter: doc.style_background_clear has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -438,7 +438,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.update_document_block --fix-jsonml": "local JSONML normalization control",
|
||||
"doc.update_document_block --heading": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --level": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --text": "aggregate convenience input used to build element",
|
||||
"doc.update_document_block --content": "aggregate convenience input used to build element",
|
||||
"doc.version_list --cursor": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --cursor is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --limit": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --limit is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"doc.version_list --node": "Reviewed unpinned adapter: doc.version_list has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -659,7 +659,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.write_image --file": "local upload input used to obtain resourceId/resourceUrl",
|
||||
"sheet.write_image --mime-type": "local upload metadata",
|
||||
"sheet.write_image --name": "local upload metadata",
|
||||
"todo.add_todo_attachment --file-path": "local upload input used to construct attachmentList",
|
||||
"todo.add_todo_attachment --file": "local upload input used to construct attachmentList",
|
||||
"todo.get_user_todos_in_current_org --query-all": "Local route selector: the default path calls get_user_todos_in_current_org, while --query-all switches to get_user_todos; it is not a property of the pinned default RPC.",
|
||||
"todo.list_todo_attachment --task-id": "Reviewed unpinned adapter: --task-id is nested under todoAttachmentListRequest at runtime, while the immutable pinned MCP snapshot has no interface_ref for todo.list_todo_attachment.",
|
||||
"wiki.create_wikiSpace --icon": "runtime extension sends icon, which is absent from the pinned create_wikiSpace metadata",
|
||||
|
||||
@@ -77,7 +77,7 @@ func cloneRuntimeSchemaStringMap(source map[string]string) map[string]string {
|
||||
|
||||
func init() {
|
||||
RegisterRuntimeSchemaParameterMetadata("aisearch.enterprise_person_search", RuntimeSchemaParameterMetadata{
|
||||
Required: []string{"keyword"},
|
||||
Required: []string{"query"},
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("aitable.export_data", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
@@ -146,8 +146,8 @@ func init() {
|
||||
})
|
||||
RegisterRuntimeSchemaParameterMetadata("chat.send_personal_message", RuntimeSchemaParameterMetadata{
|
||||
RequiredWhen: map[string]string{
|
||||
"media-id": "msg-type is image",
|
||||
"file-path": "msg-type is file or audio or video",
|
||||
"media-id": "msg-type is image",
|
||||
"file": "msg-type is file or audio or video",
|
||||
},
|
||||
Examples: map[string]string{"media-id": "@lADP_schema_smoke"},
|
||||
})
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -361,13 +361,18 @@ func TestCrossPlatformCoverageDaemonMethodEdges(t *testing.T) {
|
||||
}
|
||||
return nil, net.ErrClosed
|
||||
}}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
d := &daemon{listener: l, log: logger, hub: NewHub(1), idleStop: make(chan struct{}), stopReq: make(chan string, 1)}
|
||||
d.acceptLoop(context.Background())
|
||||
d.shuttingDown.Store(true)
|
||||
d.acceptLoop(context.Background())
|
||||
d.triggerShutdown("test")
|
||||
if !l.closed {
|
||||
t.Fatal("trigger did not close listener")
|
||||
select {
|
||||
case reason := <-d.stopReq:
|
||||
if reason != "test" {
|
||||
t.Fatalf("stop reason = %q", reason)
|
||||
}
|
||||
default:
|
||||
t.Fatal("trigger did not notify lifecycle loop")
|
||||
}
|
||||
|
||||
d = &daemon{listener: &scriptedListener{accept: func() (net.Conn, error) { return nil, net.ErrClosed }}, log: logger, hub: NewHub(1), idleStop: make(chan struct{})}
|
||||
|
||||
@@ -186,6 +186,7 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
dedup: dd,
|
||||
started: time.Now().UTC(),
|
||||
idleStop: make(chan struct{}),
|
||||
stopReq: make(chan string, 1),
|
||||
}
|
||||
|
||||
// 4. Signal ready BEFORE accepting consumers (avoids a slow-fork
|
||||
@@ -263,6 +264,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
}
|
||||
case <-d.idleStop:
|
||||
log.Info("bus: idle timeout reached, shutting down")
|
||||
case reason := <-d.stopReq:
|
||||
shutdownReason = reason
|
||||
log.Info("bus: shutdown requested via IPC", "reason", reason)
|
||||
}
|
||||
|
||||
// 7. Graceful shutdown — cancel runCtx first so all background
|
||||
@@ -292,6 +296,7 @@ type daemon struct {
|
||||
shutdownMu sync.Mutex
|
||||
shuttingDown atomic.Bool
|
||||
idleStop chan struct{}
|
||||
stopReq chan string
|
||||
|
||||
credentialHandoffMu sync.Mutex
|
||||
terminalMu sync.RWMutex
|
||||
@@ -749,15 +754,17 @@ func (d *daemon) idleWatch(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus.
|
||||
// It works by closing the listener (which unblocks Run's select via the
|
||||
// source error path, indirectly). For v1 a full ctx-cancellation hook is
|
||||
// out of scope; busctl/stop also sends SIGTERM which is the authoritative
|
||||
// shutdown path.
|
||||
// triggerShutdown is called from RPC handlers that want to end the bus. The
|
||||
// buffered request wakes Run's lifecycle select, which then cancels the cloud
|
||||
// source and performs the same graceful shutdown used for signals and idle
|
||||
// expiry. It is intentionally non-blocking so repeated stop requests cannot
|
||||
// strand connection handlers.
|
||||
func (d *daemon) triggerShutdown(reason string) {
|
||||
d.log.Info("bus: shutdown triggered via IPC", "reason", reason)
|
||||
_ = d.listener.Close() // unblocks Accept(), but doesn't kill Source
|
||||
// Best-effort: a future version wires a context.CancelFunc here.
|
||||
select {
|
||||
case d.stopReq <- reason:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// shutdown performs the graceful tear-down sequence:
|
||||
|
||||
@@ -141,6 +141,67 @@ func TestDaemon_RunStartsAndShutsDownCleanly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDaemonIPCStopShutsDownCleanly(t *testing.T) {
|
||||
workDir := shortTempDir(t)
|
||||
identityHash := dwsevent.IdentityHash(workDir)
|
||||
endpoint := dwsevent.IPCEndpoint(workDir, "open", dwsevent.SourceKindAppStream, identityHash)
|
||||
runDone := make(chan error, 1)
|
||||
go func() {
|
||||
runDone <- Run(context.Background(), Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: endpoint,
|
||||
ClientID: "ding_ipc_stop_test",
|
||||
Edition: "open",
|
||||
SourceKind: dwsevent.SourceKindAppStream,
|
||||
IdentityHash: identityHash,
|
||||
Source: &fakeSource{},
|
||||
})
|
||||
}()
|
||||
|
||||
var conn net.Conn
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var err error
|
||||
conn, err = transport.Dial(endpoint)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if conn == nil {
|
||||
t.Fatal("bus IPC endpoint did not become ready")
|
||||
}
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStop,
|
||||
}); err != nil {
|
||||
t.Fatalf("write stop hello: %v", err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
t.Fatalf("read stop response: %v", err)
|
||||
}
|
||||
_ = conn.Close()
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
|
||||
t.Fatalf("stop response = %#v", bye)
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-runDone:
|
||||
if err != nil {
|
||||
t.Fatalf("Run returned after IPC stop: %v", err)
|
||||
}
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run did not return after IPC stop")
|
||||
}
|
||||
if pid := ReadHolderPID(filepath.Join(workDir, LockFileName)); pid != 0 {
|
||||
t.Fatalf("bus lock retained pid %d after IPC stop", pid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDaemon_ConsumerReceivesEvents(t *testing.T) {
|
||||
skipOnWindows(t, "uses Unix socket dial")
|
||||
workDir := shortTempDir(t)
|
||||
|
||||
@@ -114,6 +114,47 @@ func ReadHolderPID(path string) int {
|
||||
return parsePID(b)
|
||||
}
|
||||
|
||||
// ValidateHolderOwnership reports whether pid still owns the exclusive lock at
|
||||
// path. If the lock can be acquired, no daemon owns it: the PID payload is
|
||||
// stale and is cleared while the probe holds the lock. The PID is read again
|
||||
// after a busy result so a daemon replacement cannot be mistaken for the
|
||||
// original owner.
|
||||
//
|
||||
// Callers use this immediately before sending a process-level termination
|
||||
// fallback. A live PID by itself is not proof of ownership because operating
|
||||
// systems may reuse PIDs after an unclean daemon exit.
|
||||
func ValidateHolderOwnership(path string, pid int) (bool, error) {
|
||||
if pid <= 0 || ReadHolderPID(path) != pid {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
probe, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrBusy) {
|
||||
return ReadHolderPID(path) == pid, nil
|
||||
}
|
||||
return false, fmt.Errorf("bus: verify lock owner: %w", err)
|
||||
}
|
||||
defer probe.Close()
|
||||
|
||||
// We own the lock, so the recorded PID cannot own it. Clear only the PID
|
||||
// we validated above; a changed payload is left untouched defensively.
|
||||
f := probe.File()
|
||||
if _, err := busSeek(f, 0, io.SeekStart); err != nil {
|
||||
return false, fmt.Errorf("bus: seek stale lock: %w", err)
|
||||
}
|
||||
current, err := busReadAll(f)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("bus: read stale lock: %w", err)
|
||||
}
|
||||
if parsePID(current) == pid {
|
||||
if err := truncateAndWritePID(f, 0); err != nil {
|
||||
return false, fmt.Errorf("bus: clear stale PID: %w", err)
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Close releases the flock and best-effort blanks the PID body so a stale
|
||||
// reader (e.g. `event status` racing our shutdown) does not see our
|
||||
// long-dead PID and try to signal it. The lock file itself is NOT removed
|
||||
|
||||
@@ -16,9 +16,13 @@ package bus
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
eventlock "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/lock"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestAcquire_WritesOurPID(t *testing.T) {
|
||||
@@ -112,6 +116,109 @@ func TestReadHolderPID_MalformedReturnsZero(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipHeldLock(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
held, err := busTryAcquire(path)
|
||||
if err != nil {
|
||||
t.Fatalf("hold lock: %v", err)
|
||||
}
|
||||
defer held.Close()
|
||||
if err := truncateAndWritePID(held.File(), os.Getpid()); err != nil {
|
||||
t.Fatalf("write holder PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if !owned {
|
||||
t.Fatal("held lock was not attributed to its recorded PID")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipClearsReusedPID(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
// The current PID is alive but deliberately does not hold this lock. This
|
||||
// models a stale daemon PID that the OS has reassigned to another process.
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatalf("write stale PID: %v", err)
|
||||
}
|
||||
|
||||
owned, err := ValidateHolderOwnership(path, os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateHolderOwnership: %v", err)
|
||||
}
|
||||
if owned {
|
||||
t.Fatal("live reused PID without the bus lock was accepted as owner")
|
||||
}
|
||||
if got := ReadHolderPID(path); got != 0 {
|
||||
t.Fatalf("stale PID after validation = %d, want cleared", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipRejectsMismatch(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte("123\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned, err := ValidateHolderOwnership(path, 456)
|
||||
if err != nil || owned {
|
||||
t.Fatalf("ValidateHolderOwnership mismatch = %v, %v", owned, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHolderOwnershipErrors(t *testing.T) {
|
||||
errInjected := errors.New("injected ownership validation failure")
|
||||
writeLivePID := func(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
if err := os.WriteFile(path, []byte(fmt.Sprintf("%d\n", os.Getpid())), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
t.Run("probe", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTryAcquire, func(string) (*eventlock.File, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("probe error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("seek", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busSeek, func(*os.File, int64, int) (int64, error) {
|
||||
return 0, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("seek error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busReadAll, func(io.Reader) ([]byte, error) {
|
||||
return nil, errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("read error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("clear", func(t *testing.T) {
|
||||
path := writeLivePID(t)
|
||||
testseam.Swap(t, &busTruncate, func(*os.File, int64) error {
|
||||
return errInjected
|
||||
})
|
||||
if _, err := ValidateHolderOwnership(path, os.Getpid()); !errors.Is(err, errInjected) {
|
||||
t.Fatalf("clear error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAcquire_AfterReleaseReclaimable(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), LockFileName)
|
||||
l1, err := Acquire(path)
|
||||
|
||||
@@ -284,11 +284,13 @@ func TestCrossPlatformCoverageQueryStatusAndEntryEdges(t *testing.T) {
|
||||
|
||||
func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
origRead := stopReadHolderPID
|
||||
origValidateOwner := stopValidateHolderOwner
|
||||
origAlive := stopAlive
|
||||
origFind := stopFindProcess
|
||||
origSignal := stopSignalProcess
|
||||
t.Cleanup(func() {
|
||||
stopReadHolderPID = origRead
|
||||
stopValidateHolderOwner = origValidateOwner
|
||||
stopAlive = origAlive
|
||||
stopFindProcess = origFind
|
||||
stopSignalProcess = origSignal
|
||||
@@ -307,6 +309,7 @@ func TestCrossPlatformCoverageStopInjectedEdges(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stopFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
stopValidateHolderOwner = func(string, int) (bool, error) { return true, nil }
|
||||
stopSignalProcess = func(*os.Process, os.Signal) error { return os.ErrProcessDone }
|
||||
if err := Stop(StopConfig{WorkDir: "x"}); err != nil {
|
||||
t.Fatalf("done signal = %v", err)
|
||||
|
||||
@@ -112,8 +112,9 @@ func Discover(cfg DiscoverConfig) (net.Conn, error) {
|
||||
return nil, fmt.Errorf("busctl: mkdir workdir: %w", err)
|
||||
}
|
||||
_, spawnErr := cfg.Spawn(SpawnConfig{
|
||||
ClientID: cfg.ClientID,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
ClientID: cfg.ClientID,
|
||||
IPCEndpoint: cfg.IPCEndpoint,
|
||||
ExtraArgs: cfg.SpawnExtraArgs,
|
||||
})
|
||||
if spawnErr != nil && !errors.Is(spawnErr, ErrSpawnFailed) {
|
||||
// Hard error (couldn't even exec the child). Stop here — no bus
|
||||
|
||||
@@ -122,7 +122,10 @@ func TestDiscover_NoBus_SpawnSucceeds(t *testing.T) {
|
||||
closer()
|
||||
}
|
||||
})
|
||||
fakeSpawn := func(SpawnConfig) (int, error) {
|
||||
fakeSpawn := func(cfg SpawnConfig) (int, error) {
|
||||
if cfg.IPCEndpoint != sock {
|
||||
t.Fatalf("spawn IPC endpoint = %q, want %q", cfg.IPCEndpoint, sock)
|
||||
}
|
||||
closer = startStubBus(t, sock)
|
||||
return 12345, nil
|
||||
}
|
||||
|
||||
@@ -43,18 +43,14 @@ var (
|
||||
spawnPipe = os.Pipe
|
||||
)
|
||||
|
||||
// ErrSpawnFailed is returned when the child reports startup failure via
|
||||
// the ready pipe ('E' byte). The child's exit error / log file holds the
|
||||
// actual cause; this sentinel just lets the caller distinguish "ready
|
||||
// pipe said no" from "ready pipe timed out / closed early".
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure on ready pipe")
|
||||
// ErrSpawnFailed is returned when the child reports startup failure through
|
||||
// the Unix ready pipe or exits before binding the Windows named pipe.
|
||||
var ErrSpawnFailed = errors.New("busctl: bus child reported startup failure")
|
||||
|
||||
// ErrSpawnTimeout is returned when ReadyTimeout elapses without any signal.
|
||||
var ErrSpawnTimeout = errors.New("busctl: bus child did not signal readiness within deadline")
|
||||
|
||||
// SpawnConfig describes one spawn attempt. ClientID is the only field
|
||||
// inspected by the child; the rest govern process attributes the parent
|
||||
// applies before exec.
|
||||
// SpawnConfig describes one spawn attempt.
|
||||
type SpawnConfig struct {
|
||||
// ExecPath is the dws binary to exec. Default os.Executable().
|
||||
ExecPath string
|
||||
@@ -62,18 +58,23 @@ type SpawnConfig struct {
|
||||
// ClientID is passed as `--client-id` to `dws event _bus`. Required.
|
||||
ClientID string
|
||||
|
||||
// IPCEndpoint is the bus endpoint the child binds. Windows uses the
|
||||
// existing named pipe as its readiness handshake because os/exec does not
|
||||
// support ExtraFiles there. Unix keeps the inherited ready-pipe protocol.
|
||||
IPCEndpoint string
|
||||
|
||||
// ExtraArgs are appended after `--client-id`. Empty for normal use; tests
|
||||
// pass `--extra-flag-for-test` etc.
|
||||
ExtraArgs []string
|
||||
|
||||
// Env to pass to the child. Defaults to os.Environ(). The ReadyFDEnv
|
||||
// entry is appended automatically.
|
||||
// Env to pass to the child. Defaults to os.Environ(). Unix appends the
|
||||
// ReadyFDEnv entry automatically; Windows removes any inherited copy.
|
||||
Env []string
|
||||
}
|
||||
|
||||
// Spawn forks a detached `dws event _bus --client-id <id>` child process and
|
||||
// waits for it to signal readiness via the ready pipe. Returns the child's
|
||||
// PID on success — the caller can then dial the bus IPC endpoint.
|
||||
// spawnWithReadyPipe is the Unix implementation behind Spawn. It forks a
|
||||
// detached `dws event _bus --client-id <id>` child and waits for the inherited
|
||||
// ready pipe. Windows has a named-pipe implementation in spawn_windows.go.
|
||||
//
|
||||
// stdio detach (plan invariant #7):
|
||||
// - cmd.Stdout / cmd.Stderr set to nil so the child's own writes don't
|
||||
@@ -89,7 +90,7 @@ type SpawnConfig struct {
|
||||
// Parent (this function):
|
||||
// - Holds the read end open until either 1 byte is read or ReadyTimeout
|
||||
// - Returns ErrSpawnFailed for 'E', ErrSpawnTimeout otherwise
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
func spawnWithReadyPipe(cfg SpawnConfig) (pid int, err error) {
|
||||
if cfg.ClientID == "" {
|
||||
return 0, errors.New("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// Test child mode: when DWS_BUSCTL_TEST_CHILD is set, this test binary
|
||||
@@ -35,7 +37,8 @@ import (
|
||||
// env-marker pattern is what Go's own os/exec tests use and stays
|
||||
// confined to this file.
|
||||
const (
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEnvMarker = "DWS_BUSCTL_TEST_CHILD"
|
||||
childEndpointEnv = "DWS_BUSCTL_TEST_ENDPOINT"
|
||||
// values:
|
||||
// "ready" — write 'R' then sleep 30s (parent should see ready)
|
||||
// "fail" — write 'E' then exit (parent should see ErrSpawnFailed)
|
||||
@@ -70,6 +73,26 @@ func TestMain(m *testing.M) {
|
||||
writeReady('R')
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-ready":
|
||||
listener, err := transport.Listen(os.Getenv(childEndpointEnv))
|
||||
if err != nil {
|
||||
os.Exit(3)
|
||||
}
|
||||
defer listener.Close()
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
os.Exit(4)
|
||||
}
|
||||
_ = conn.Close()
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
case "windows-fail":
|
||||
os.Exit(5)
|
||||
case "windows-exit":
|
||||
os.Exit(0)
|
||||
case "windows-stall":
|
||||
time.Sleep(30 * time.Second)
|
||||
os.Exit(0)
|
||||
}
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Spawn starts the detached bus and waits for its inherited ready pipe.
|
||||
// ExtraFiles is intentionally confined to Unix: Go does not support it on
|
||||
// Windows.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
return spawnWithReadyPipe(cfg)
|
||||
}
|
||||
|
||||
// applyDetach configures the child to live past parent death and not share
|
||||
// the parent's controlling terminal. Setsid puts the child in a new
|
||||
// session, so SIGHUP on the parent's controlling tty (e.g. SSH disconnect)
|
||||
|
||||
@@ -16,14 +16,101 @@
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// CREATE_NEW_PROCESS_GROUP (0x00000200) prevents the child from receiving
|
||||
// the parent's Ctrl+C signal, similar in spirit to Setsid on Unix.
|
||||
const createNewProcessGroup = 0x00000200
|
||||
|
||||
var (
|
||||
spawnWindowsDial = transport.Dial
|
||||
spawnWindowsPollInterval = 25 * time.Millisecond
|
||||
)
|
||||
|
||||
// Spawn starts a detached Windows bus without cmd.ExtraFiles (unsupported by
|
||||
// Go on Windows). Readiness is confirmed by dialing the bus's existing named
|
||||
// pipe. The child is reaped in the background after readiness, while an early
|
||||
// process exit is surfaced as ErrSpawnFailed.
|
||||
func Spawn(cfg SpawnConfig) (pid int, err error) {
|
||||
if strings.TrimSpace(cfg.ClientID) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.ClientID is required")
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) == "" {
|
||||
return 0, fmt.Errorf("busctl: SpawnConfig.IPCEndpoint is required on Windows")
|
||||
}
|
||||
if cfg.ExecPath == "" {
|
||||
execPath, err := spawnExecutable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("busctl: locate executable: %w", err)
|
||||
}
|
||||
cfg.ExecPath = execPath
|
||||
}
|
||||
if cfg.Env == nil {
|
||||
cfg.Env = os.Environ()
|
||||
}
|
||||
|
||||
args := append([]string{"event", "_bus", "--client-id", cfg.ClientID}, cfg.ExtraArgs...)
|
||||
cmd := exec.Command(cfg.ExecPath, args...)
|
||||
cmd.Env = withoutReadyFDEnv(cfg.Env)
|
||||
cmd.Stdin = nil
|
||||
cmd.Stdout = nil
|
||||
cmd.Stderr = nil
|
||||
applyDetach(cmd)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return 0, fmt.Errorf("busctl: start %s: %w", cfg.ExecPath, err)
|
||||
}
|
||||
pid = cmd.Process.Pid
|
||||
|
||||
waitDone := make(chan error, 1)
|
||||
go func() { waitDone <- cmd.Wait() }()
|
||||
timer := time.NewTimer(ReadyTimeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(spawnWindowsPollInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if conn, dialErr := spawnWindowsDial(cfg.IPCEndpoint); dialErr == nil {
|
||||
_ = conn.Close()
|
||||
return pid, nil
|
||||
}
|
||||
select {
|
||||
case waitErr := <-waitDone:
|
||||
if waitErr == nil {
|
||||
return pid, fmt.Errorf("%w: child exited before binding named pipe", ErrSpawnFailed)
|
||||
}
|
||||
return pid, fmt.Errorf("%w: %v", ErrSpawnFailed, waitErr)
|
||||
case <-timer.C:
|
||||
_ = cmd.Process.Kill()
|
||||
select {
|
||||
case <-waitDone:
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
return pid, ErrSpawnTimeout
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withoutReadyFDEnv(env []string) []string {
|
||||
prefix := strings.ToUpper(ReadyFDEnv) + "="
|
||||
out := make([]string, 0, len(env))
|
||||
for _, entry := range env {
|
||||
if strings.HasPrefix(strings.ToUpper(entry), prefix) {
|
||||
continue
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func applyDetach(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{
|
||||
CreationFlags: createNewProcessGroup,
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
// 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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnUsesNamedPipeReadiness(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-test-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn() = %v", err)
|
||||
}
|
||||
if pid <= 0 {
|
||||
t.Fatalf("Spawn() pid = %d", pid)
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReportsEarlyExit(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-fail-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
_, err := spawnWithMarker(t, "windows-fail", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReportsCleanEarlyExit(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-exit-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
_, err := spawnWithMarker(t, "windows-exit", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnFailed) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnFailed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnResolvesExecutable(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-resolve-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
testseam.Swap(t, &spawnExecutable, func() (string, error) { return os.Args[0], nil })
|
||||
pid, err := spawnWithMarker(t, "windows-ready", func(cfg *SpawnConfig) {
|
||||
cfg.ExecPath = ""
|
||||
cfg.IPCEndpoint = endpoint
|
||||
cfg.Env = append(cfg.Env, childEndpointEnv+"="+endpoint)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Spawn() = %v", err)
|
||||
}
|
||||
proc, err := os.FindProcess(pid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = proc.Kill()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnValidationAndStartErrors(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-errors-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
if _, err := Spawn(SpawnConfig{ClientID: "client"}); err == nil {
|
||||
t.Fatal("missing endpoint unexpectedly succeeded")
|
||||
}
|
||||
testseam.Swap(t, &spawnExecutable, func() (string, error) {
|
||||
return "", errors.New("executable unavailable")
|
||||
})
|
||||
if _, err := Spawn(SpawnConfig{ClientID: "client", IPCEndpoint: endpoint}); err == nil {
|
||||
t.Fatal("executable lookup unexpectedly succeeded")
|
||||
}
|
||||
if _, err := Spawn(SpawnConfig{
|
||||
ClientID: "client",
|
||||
IPCEndpoint: endpoint,
|
||||
ExecPath: `C:\\definitely-missing-dws.exe`,
|
||||
}); err == nil {
|
||||
t.Fatal("missing executable unexpectedly started")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnReadyTimeoutTerminatesChild(t *testing.T) {
|
||||
endpoint := fmt.Sprintf(`\\.\pipe\dws-event-spawn-stall-%d-%d`, os.Getpid(), time.Now().UnixNano())
|
||||
testseam.Swap(t, &ReadyTimeout, 100*time.Millisecond)
|
||||
_, err := spawnWithMarker(t, "windows-stall", func(cfg *SpawnConfig) {
|
||||
cfg.IPCEndpoint = endpoint
|
||||
})
|
||||
if !errors.Is(err, ErrSpawnTimeout) {
|
||||
t.Fatalf("Spawn() error = %v, want ErrSpawnTimeout", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsSpawnRemovesInheritedReadyFD(t *testing.T) {
|
||||
env := withoutReadyFDEnv([]string{
|
||||
"PATH=C:\\Windows",
|
||||
ReadyFDEnv + "=3",
|
||||
strings.ToLower(ReadyFDEnv) + "=4",
|
||||
})
|
||||
if len(env) != 1 || !strings.HasPrefix(env[0], "PATH=") {
|
||||
t.Fatalf("withoutReadyFDEnv() = %#v", env)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageWindowsUnixReadyPipeValidation(t *testing.T) {
|
||||
if _, err := spawnWithReadyPipe(SpawnConfig{}); err == nil {
|
||||
t.Fatal("spawnWithReadyPipe() unexpectedly accepted an empty client ID")
|
||||
}
|
||||
}
|
||||
@@ -17,15 +17,17 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/process"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
)
|
||||
|
||||
// DefaultStopTimeout is the wall-clock budget Stop waits for the bus to
|
||||
// exit after the signal is sent. 5s covers the bus's own graceful tear-down
|
||||
// (broadcast Bye → consumer goroutines drain → cleanup) with margin.
|
||||
// DefaultStopTimeout is the wall-clock budget Stop waits for each graceful or
|
||||
// fallback termination phase. 5s covers the bus's own tear-down (broadcast
|
||||
// Bye → consumer goroutines drain → cleanup) with margin.
|
||||
const DefaultStopTimeout = 5 * time.Second
|
||||
|
||||
// ErrNotRunning indicates bus.lock either does not exist or its recorded
|
||||
@@ -33,20 +35,32 @@ const DefaultStopTimeout = 5 * time.Second
|
||||
// distinguish "nothing to stop" from "failed to stop".
|
||||
var ErrNotRunning = errors.New("busctl: bus is not running")
|
||||
|
||||
// ErrOwnerUnverified indicates the PID from bus.lock is alive but no longer
|
||||
// owns that lock. Stop refuses to send a process-level signal in this state
|
||||
// because the operating system may have reused the stale PID.
|
||||
var ErrOwnerUnverified = errors.New("busctl: bus process ownership could not be verified")
|
||||
|
||||
var (
|
||||
stopReadHolderPID = bus.ReadHolderPID
|
||||
stopAlive = process.Alive
|
||||
stopFindProcess = os.FindProcess
|
||||
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
|
||||
stopReadHolderPID = bus.ReadHolderPID
|
||||
stopValidateHolderOwner = bus.ValidateHolderOwnership
|
||||
stopAlive = process.Alive
|
||||
stopFindProcess = os.FindProcess
|
||||
stopSignalProcess = func(proc *os.Process, signal os.Signal) error { return proc.Signal(signal) }
|
||||
stopDial = transport.Dial
|
||||
stopRequest = requestBusStop
|
||||
stopWaitForBusExit = waitForBusExit
|
||||
)
|
||||
|
||||
// StopConfig identifies the target bus and tunes timing.
|
||||
type StopConfig struct {
|
||||
// WorkDir holds bus.lock; Stop reads the PID from there.
|
||||
WorkDir string
|
||||
// Timeout is the total wall-clock budget for graceful exit. After this,
|
||||
// Stop returns an error; it does NOT escalate to SIGKILL — leave that
|
||||
// to the operator.
|
||||
// IPCEndpoint enables the cross-platform graceful stop RPC. Callers that
|
||||
// know the bus identity should always provide it. Empty preserves the
|
||||
// legacy signal-only fallback for compatibility and focused tests.
|
||||
IPCEndpoint string
|
||||
// Timeout is the wall-clock budget for graceful exit and, if required,
|
||||
// the subsequent platform termination fallback.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
@@ -54,13 +68,12 @@ type StopConfig struct {
|
||||
// for the process to actually die. Returns ErrNotRunning if no bus is
|
||||
// running for that work dir.
|
||||
//
|
||||
// Implementation note: on Unix we send SIGTERM. The bus daemon's Run loop
|
||||
// watches its parent ctx for cancellation; the cobra `event _bus` command
|
||||
// wires signal.NotifyContext so SIGTERM triggers ctx.Done() → graceful
|
||||
// shutdown path. On Windows we use os.Process.Signal(os.Interrupt) which
|
||||
// the Go runtime maps to TerminateProcess for processes outside our
|
||||
// console group; for v1 that's acceptable (Windows graceful shutdown is
|
||||
// future work — plan §16 v2).
|
||||
// Stop first asks the bus to shut down through its owner-only IPC endpoint.
|
||||
// This is the normal path on every platform and lets the daemon cancel its
|
||||
// cloud source, notify consumers, and release its lock. If the endpoint is
|
||||
// unavailable (for example an older bus) or graceful shutdown times out, the
|
||||
// platform signal is used as a compatibility fallback: SIGTERM on Unix and
|
||||
// TerminateProcess via os.Kill on Windows.
|
||||
func Stop(cfg StopConfig) error {
|
||||
if cfg.WorkDir == "" {
|
||||
return errors.New("busctl: StopConfig.WorkDir is required")
|
||||
@@ -81,6 +94,32 @@ func Stop(cfg StopConfig) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: find process %d: %w", pid, err)
|
||||
}
|
||||
if strings.TrimSpace(cfg.IPCEndpoint) != "" {
|
||||
if err := stopRequest(cfg.IPCEndpoint); err == nil && stopWaitForBusExit(pid, cfg.Timeout) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// The bus may finish shutting down immediately after the graceful wait
|
||||
// reaches its deadline (or after a failed IPC request). Recheck before
|
||||
// inspecting ownership so a completed stop is not reported as stale.
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
owner, err := stopValidateHolderOwner(LockPath(cfg.WorkDir), pid)
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: verify bus pid=%d ownership: %w", pid, err)
|
||||
}
|
||||
if !owner {
|
||||
// ValidateHolderOwnership observes a released lock both for a stale,
|
||||
// reused PID and for a bus that exits during the ownership probe. Only
|
||||
// the still-alive case is unsafe to signal.
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: pid=%d does not own %s; stale PID was not signalled", ErrOwnerUnverified, pid, LockPath(cfg.WorkDir))
|
||||
}
|
||||
|
||||
if err := stopSignalProcess(proc, stopSignal()); err != nil {
|
||||
// On many Unix platforms Signal returns "process already finished"
|
||||
// when the bus has just exited on its own — treat that as success.
|
||||
@@ -90,13 +129,45 @@ func Stop(cfg StopConfig) error {
|
||||
return fmt.Errorf("busctl: signal bus pid=%d: %w", pid, err)
|
||||
}
|
||||
|
||||
// Poll for actual exit.
|
||||
deadline := time.Now().Add(cfg.Timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !stopAlive(pid) {
|
||||
return nil
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if stopWaitForBusExit(pid, cfg.Timeout) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("busctl: bus pid=%d did not exit within %s", pid, cfg.Timeout)
|
||||
}
|
||||
|
||||
func requestBusStop(endpoint string) error {
|
||||
conn, err := stopDial(endpoint)
|
||||
if err != nil {
|
||||
return fmt.Errorf("busctl: dial bus for stop: %w", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetDeadline(time.Now().Add(DefaultStatusRPCTimeout))
|
||||
w := transport.NewWriter(conn)
|
||||
r := transport.NewReader(conn)
|
||||
if err := w.WriteJSON(transport.Hello{
|
||||
Type: transport.FrameTypeHello,
|
||||
ConsumerPID: os.Getpid(),
|
||||
Role: transport.HelloRoleStop,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("busctl: write stop hello: %w", err)
|
||||
}
|
||||
var bye transport.Bye
|
||||
if err := r.ReadJSON(&bye); err != nil {
|
||||
return fmt.Errorf("busctl: read stop response: %w", err)
|
||||
}
|
||||
if bye.Type != transport.FrameTypeBye || bye.Reason != "stop_request" {
|
||||
return fmt.Errorf("busctl: unexpected stop response type=%q reason=%q", bye.Type, bye.Reason)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func waitForBusExit(pid int, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
if !stopAlive(pid) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
return !stopAlive(pid)
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ package busctl
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -23,7 +24,10 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestStop_NotRunningWhenLockMissing(t *testing.T) {
|
||||
@@ -34,7 +38,7 @@ func TestStop_NotRunningWhenLockMissing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestStop_NotRunningWhenPIDDead(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopNotRunningWhenPIDDead(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
// Write a definitely-dead PID into bus.lock.
|
||||
if err := os.WriteFile(LockPath(dir), []byte("2147483646\n"), 0o600); err != nil {
|
||||
@@ -46,6 +50,269 @@ func TestStop_NotRunningWhenPIDDead(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopPrefersGracefulIPC(t *testing.T) {
|
||||
const pid = 4242
|
||||
stopped := false
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !stopped })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(endpoint string) error {
|
||||
if endpoint != "test-endpoint" {
|
||||
t.Fatalf("stop endpoint = %q", endpoint)
|
||||
}
|
||||
stopped = true
|
||||
return nil
|
||||
})
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{
|
||||
WorkDir: "test-workdir",
|
||||
IPCEndpoint: "test-endpoint",
|
||||
Timeout: time.Second,
|
||||
}); err != nil {
|
||||
t.Fatalf("Stop() = %v", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("graceful IPC stop used %d process signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopDoesNotSignalUnverifiedReusedPID(t *testing.T) {
|
||||
const pid = 4242
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return true })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return errors.New("stale endpoint") })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(path string, gotPID int) (bool, error) {
|
||||
if path != LockPath("test-workdir") || gotPID != pid {
|
||||
t.Fatalf("ownership check path=%q pid=%d", path, gotPID)
|
||||
}
|
||||
return false, nil
|
||||
})
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
err = Stop(StopConfig{
|
||||
WorkDir: "test-workdir",
|
||||
IPCEndpoint: "stale-endpoint",
|
||||
Timeout: time.Millisecond,
|
||||
})
|
||||
if !errors.Is(err, ErrOwnerUnverified) {
|
||||
t.Fatalf("Stop() error = %v, want ErrOwnerUnverified", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("unverified reused PID received %d signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopAcceptsExitAtGracefulTimeoutBoundary(t *testing.T) {
|
||||
const pid = 4242
|
||||
|
||||
t.Run("before ownership validation", func(t *testing.T) {
|
||||
exited := false
|
||||
validated := false
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return nil })
|
||||
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool {
|
||||
exited = true
|
||||
return false
|
||||
})
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
validated = true
|
||||
return false, nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
|
||||
t.Fatalf("Stop() = %v, want success after bus exit", err)
|
||||
}
|
||||
if validated {
|
||||
t.Fatal("ownership was validated after the bus had already exited")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("during ownership validation", func(t *testing.T) {
|
||||
exited := false
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return !exited })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopRequest, func(string) error { return nil })
|
||||
testseam.Swap(t, &stopWaitForBusExit, func(int, time.Duration) bool { return false })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
exited = true
|
||||
return false, nil
|
||||
})
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
if err := Stop(StopConfig{WorkDir: "test-workdir", IPCEndpoint: "test-endpoint"}); err != nil {
|
||||
t.Fatalf("Stop() = %v, want success after exit during ownership validation", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("exited bus received %d fallback signals", signals)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageStopReportsOwnershipValidationError(t *testing.T) {
|
||||
const pid = 4242
|
||||
errInjected := errors.New("ownership validation failed")
|
||||
testseam.Swap(t, &stopReadHolderPID, func(string) int { return pid })
|
||||
testseam.Swap(t, &stopAlive, func(int) bool { return true })
|
||||
proc, err := os.FindProcess(os.Getpid())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
testseam.Swap(t, &stopFindProcess, func(int) (*os.Process, error) { return proc, nil })
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) {
|
||||
return false, errInjected
|
||||
})
|
||||
signals := 0
|
||||
testseam.Swap(t, &stopSignalProcess, func(*os.Process, os.Signal) error {
|
||||
signals++
|
||||
return nil
|
||||
})
|
||||
|
||||
err = Stop(StopConfig{WorkDir: "test-workdir"})
|
||||
if !errors.Is(err, errInjected) {
|
||||
t.Fatalf("Stop() error = %v, want injected ownership error", err)
|
||||
}
|
||||
if signals != 0 {
|
||||
t.Fatalf("ownership validation error sent %d signals", signals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequestBusStopProtocol(t *testing.T) {
|
||||
dir := shortTempDir(t)
|
||||
endpoint := dwsevent.IPCEndpoint(
|
||||
dir,
|
||||
"open",
|
||||
dwsevent.SourceKindAppStream,
|
||||
dwsevent.IdentityHash(dir),
|
||||
)
|
||||
listener, err := transport.Listen(endpoint)
|
||||
if err != nil {
|
||||
t.Fatalf("Listen() = %v", err)
|
||||
}
|
||||
defer listener.Close()
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
conn, err := listener.Accept()
|
||||
if err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
r := transport.NewReader(conn)
|
||||
w := transport.NewWriter(conn)
|
||||
var hello transport.Hello
|
||||
if err := r.ReadJSON(&hello); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
if hello.Type != transport.FrameTypeHello || hello.Role != transport.HelloRoleStop {
|
||||
serverDone <- errors.New("unexpected stop hello")
|
||||
return
|
||||
}
|
||||
serverDone <- w.WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "stop_request",
|
||||
})
|
||||
}()
|
||||
|
||||
if err := requestBusStop(endpoint); err != nil {
|
||||
t.Fatalf("requestBusStop() = %v", err)
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("stop protocol server = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRequestBusStopErrors(t *testing.T) {
|
||||
t.Run("dial", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return nil, errors.New("dial failed")
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("write", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return &queryErrorConn{failAt: 1}, nil
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("read", func(t *testing.T) {
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return &queryErrorConn{}, nil
|
||||
})
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unexpected response", func(t *testing.T) {
|
||||
client, server := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = client.Close()
|
||||
_ = server.Close()
|
||||
})
|
||||
testseam.Swap(t, &stopDial, func(string) (net.Conn, error) {
|
||||
return client, nil
|
||||
})
|
||||
serverDone := make(chan error, 1)
|
||||
go func() {
|
||||
var hello transport.Hello
|
||||
if err := transport.NewReader(server).ReadJSON(&hello); err != nil {
|
||||
serverDone <- err
|
||||
return
|
||||
}
|
||||
serverDone <- transport.NewWriter(server).WriteJSON(transport.Bye{
|
||||
Type: transport.FrameTypeBye,
|
||||
Reason: "unexpected",
|
||||
})
|
||||
}()
|
||||
if err := requestBusStop("test-endpoint"); err == nil {
|
||||
t.Fatal("requestBusStop() unexpectedly succeeded")
|
||||
}
|
||||
if err := <-serverDone; err != nil {
|
||||
t.Fatalf("stop protocol server = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStop_SignalsLiveProcess(t *testing.T) {
|
||||
skipOnWindows(t)
|
||||
dir := shortTempDir(t)
|
||||
@@ -63,6 +330,7 @@ func TestStop_SignalsLiveProcess(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
|
||||
|
||||
// Reap the child in background so Wait doesn't leave a zombie.
|
||||
waited := make(chan error, 1)
|
||||
@@ -111,6 +379,7 @@ func TestStop_TimeoutWhenChildIgnoresSignal(t *testing.T) {
|
||||
_, _ = cmd.Process.Wait()
|
||||
}()
|
||||
pid := cmd.Process.Pid
|
||||
testseam.Swap(t, &stopValidateHolderOwner, func(string, int) (bool, error) { return true, nil })
|
||||
|
||||
if err := os.WriteFile(LockPath(dir), []byte(strconv.Itoa(pid)+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -17,8 +17,7 @@ package busctl
|
||||
|
||||
import "os"
|
||||
|
||||
// stopSignal returns the graceful-shutdown signal for Windows. The Go
|
||||
// runtime maps os.Interrupt to TerminateProcess for non-console-group
|
||||
// processes — not truly graceful, but acceptable for v1 (Windows graceful
|
||||
// shutdown via Ctrl+Break is in the v2 backlog, plan §16).
|
||||
func stopSignal() os.Signal { return os.Interrupt }
|
||||
// stopSignal is only the fallback after the graceful IPC stop path fails or
|
||||
// times out. Go maps os.Kill to TerminateProcess on Windows; os.Interrupt is
|
||||
// unsupported and returns syscall.EWINDOWS.
|
||||
func stopSignal() os.Signal { return os.Kill }
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
// 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.
|
||||
|
||||
//go:build windows
|
||||
|
||||
package busctl
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageWindowsStopFallbackUsesKill(t *testing.T) {
|
||||
if stopSignal() != os.Kill {
|
||||
t.Fatalf("stopSignal() = %v, want os.Kill", stopSignal())
|
||||
}
|
||||
}
|
||||
@@ -132,6 +132,7 @@ func TestClientCreateRuleBasedSubscriptionsUsesDocumentedRuleParam(t *testing.T)
|
||||
{"oa_approval_task_finished", EventOAApprovalTaskFinished, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_task_redirected", EventOAApprovalTaskRedirected, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_started", EventOAApprovalInstanceStarted, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_cc", EventOAApprovalInstanceCC, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_terminated", EventOAApprovalInstanceTerminated, RuleOptions{}, map[string]any{}},
|
||||
{"oa_approval_instance_finished", EventOAApprovalInstanceFinished, RuleOptions{}, map[string]any{}},
|
||||
{"read_group", EventReadGroup, RuleOptions{GroupID: "cid-1"}, map[string]any{"openConversationId": "cid-1"}},
|
||||
|
||||
@@ -180,6 +180,19 @@ type OAApprovalInstanceStartedOutput struct {
|
||||
EventTime int64 `json:"event_time" description:"审批实例事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceCCOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
Timestamp int64 `json:"timestamp" description:"事件发生时间戳" format:"timestamp_ms"`
|
||||
SubscribeID string `json:"subscribe_id" description:"订阅 ID"`
|
||||
ProcessInstanceID string `json:"process_instance_id" description:"审批实例 ID"`
|
||||
ProcessCode string `json:"process_code" description:"审批流程模板编码"`
|
||||
Title string `json:"title" description:"审批标题"`
|
||||
Status string `json:"status" description:"审批实例到达抄送节点时的状态"`
|
||||
CreateTime int64 `json:"create_time" description:"审批实例创建时间" format:"timestamp_ms"`
|
||||
EventTime int64 `json:"event_time" description:"审批抄送事件业务时间" format:"timestamp_ms"`
|
||||
}
|
||||
|
||||
type OAApprovalInstanceTerminatedOutput struct {
|
||||
Type string `json:"type" description:"事件类型,固定为当前 event_key"`
|
||||
EventID string `json:"event_id" description:"事件 ID,可用于去重"`
|
||||
@@ -667,6 +680,19 @@ func projectOAApprovalEvent(ev transport.Event, base baseEventOutput, raw json.R
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceCC:
|
||||
return OAApprovalInstanceCCOutput{
|
||||
Type: base.Type,
|
||||
EventID: base.EventID,
|
||||
Timestamp: base.Timestamp,
|
||||
SubscribeID: base.SubscribeID,
|
||||
ProcessInstanceID: payload.Body.ProcessInstanceID,
|
||||
ProcessCode: payload.Body.ProcessCode,
|
||||
Title: payload.Body.Title,
|
||||
Status: payload.Body.Status,
|
||||
CreateTime: payload.Body.CreateTime,
|
||||
EventTime: payload.EventTime,
|
||||
}, nil
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
return OAApprovalInstanceTerminatedOutput{
|
||||
Type: base.Type,
|
||||
@@ -871,6 +897,8 @@ func outputTypeForEvent(eventKey string) reflect.Type {
|
||||
return reflect.TypeOf(OAApprovalTaskRedirectedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceStarted:
|
||||
return reflect.TypeOf(OAApprovalInstanceStartedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceCC:
|
||||
return reflect.TypeOf(OAApprovalInstanceCCOutput{})
|
||||
case eventKey == EventOAApprovalInstanceTerminated:
|
||||
return reflect.TypeOf(OAApprovalInstanceTerminatedOutput{})
|
||||
case eventKey == EventOAApprovalInstanceFinished:
|
||||
@@ -906,6 +934,7 @@ func isOAEvent(eventKey string) bool {
|
||||
eventKey == EventOAApprovalTaskFinished ||
|
||||
eventKey == EventOAApprovalTaskRedirected ||
|
||||
eventKey == EventOAApprovalInstanceStarted ||
|
||||
eventKey == EventOAApprovalInstanceCC ||
|
||||
eventKey == EventOAApprovalInstanceTerminated ||
|
||||
eventKey == EventOAApprovalInstanceFinished
|
||||
}
|
||||
|
||||
@@ -173,6 +173,8 @@ func personalOAData(eventKey string) string {
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
case EventOAApprovalInstanceStarted:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceCC:
|
||||
body["status"] = "RUNNING"
|
||||
case EventOAApprovalInstanceTerminated:
|
||||
body["status"] = "TERMINATED"
|
||||
body["finishTime"] = int64(1785229199000)
|
||||
@@ -521,6 +523,21 @@ func TestCrossPlatformCoverageProjectOutputOAEvents(t *testing.T) {
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
want: OAApprovalInstanceCCOutput{
|
||||
Type: EventOAApprovalInstanceCC,
|
||||
EventID: "oa-event",
|
||||
Timestamp: 1785229200123,
|
||||
SubscribeID: "outer-sub",
|
||||
ProcessInstanceID: "process-instance-1",
|
||||
ProcessCode: "PROC-TEST-1",
|
||||
Title: "测试审批",
|
||||
Status: "RUNNING",
|
||||
CreateTime: 1785229100000,
|
||||
EventTime: 1785229199000,
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
want: OAApprovalInstanceTerminatedOutput{
|
||||
@@ -785,6 +802,7 @@ func TestCrossPlatformCoverageProjectOutputRejectsInvalidOAPayloads(t *testing.T
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
|
||||
@@ -43,6 +43,7 @@ const (
|
||||
EventOAApprovalTaskFinished = "user_oa_approval_task_finished"
|
||||
EventOAApprovalTaskRedirected = "user_oa_approval_task_redirected"
|
||||
EventOAApprovalInstanceStarted = "user_oa_approval_instance_started"
|
||||
EventOAApprovalInstanceCC = "user_oa_approval_instance_cc"
|
||||
EventOAApprovalInstanceTerminated = "user_oa_approval_instance_terminated"
|
||||
EventOAApprovalInstanceFinished = "user_oa_approval_instance_finished"
|
||||
)
|
||||
@@ -323,6 +324,17 @@ var definitions = []Definition{
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceCC,
|
||||
DisplayName: "审批单抄送",
|
||||
Description: "审批实例到达抄送节点,发送给被抄送人",
|
||||
Category: "oa",
|
||||
RuleType: "all",
|
||||
Status: StatusEnabled,
|
||||
RequiredParams: nil,
|
||||
Auth: map[string]any{"identity": "user"},
|
||||
Public: true,
|
||||
},
|
||||
{
|
||||
EventKey: EventOAApprovalInstanceTerminated,
|
||||
DisplayName: "审批单终止",
|
||||
|
||||
@@ -50,6 +50,7 @@ func TestCatalogEnabledEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -65,6 +66,7 @@ func TestOAEventCatalogDefinitions(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
}
|
||||
@@ -158,6 +160,7 @@ func TestSchemaDocumentsDefaultToTransportEnvelope(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -509,6 +512,13 @@ func TestOAEventSchemaDocumentsMatchOutputDTO(t *testing.T) {
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceCC,
|
||||
properties: []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "process_instance_id",
|
||||
"process_code", "title", "status", "create_time", "event_time",
|
||||
},
|
||||
},
|
||||
{
|
||||
eventKey: EventOAApprovalInstanceTerminated,
|
||||
properties: []string{
|
||||
@@ -633,6 +643,7 @@ func TestBuildRuleParamAllEvents(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
} {
|
||||
@@ -845,6 +856,7 @@ func TestSupportsMessageFilter(t *testing.T) {
|
||||
EventOAApprovalTaskFinished,
|
||||
EventOAApprovalTaskRedirected,
|
||||
EventOAApprovalInstanceStarted,
|
||||
EventOAApprovalInstanceCC,
|
||||
EventOAApprovalInstanceTerminated,
|
||||
EventOAApprovalInstanceFinished,
|
||||
"unknown_event",
|
||||
|
||||
@@ -3,18 +3,21 @@ package helpers
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
// boolPtr returns a pointer to v, for ParamDecl.Required declarations.
|
||||
func boolPtr(v bool) *bool { return &v }
|
||||
|
||||
// aisearchKeywordAliases 是 --keyword flag 的同义瞎猜兜底列表。
|
||||
// 模型可能写 --name / --q / --query / --text,这些都被识别为 keyword。
|
||||
var aisearchKeywordAliases = []string{"name", "q", "query", "text"}
|
||||
// 这些是原有的宽松兜底名,不属于本次 Primary 迁移;保持原生 hidden
|
||||
// 注册,不给它们新增 alias_of/origin 元数据。
|
||||
var aisearchPersonGuessFlags = []string{"name", "q", "text"}
|
||||
|
||||
// 历史解析优先级必须独立于新旧 Primary:双传时旧 --keyword 始终获胜。
|
||||
var aisearchQueryResolutionOrder = []string{"keyword", "name", "q", "query", "text"}
|
||||
|
||||
// flagValue 安全地读取 flag 值:先查 local,再查自身 PersistentFlags,
|
||||
// 再查 parents 的 PersistentFlags。比 cmd.Flags().GetString 更鲁棒,
|
||||
@@ -58,14 +61,11 @@ func aisearchFlagOrDefault(cmd *cobra.Command, primary, def string, aliases ...s
|
||||
return def
|
||||
}
|
||||
|
||||
// resolveAisearchKeyword 从命令的 flag 中解析 keyword:优先 --keyword,
|
||||
// 否则 fallback 到 aisearchKeywordAliases 中的任一同义 flag。
|
||||
// resolveAisearchKeyword 按历史顺序解析人员搜索词。虽然公开 Primary 已改为
|
||||
// --query,但兼容期内显式旧 --keyword 的值仍优先。
|
||||
func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
if v := flagValue(cmd, "keyword"); v != "" {
|
||||
return v
|
||||
}
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
if v := flagValue(cmd, alias); v != "" {
|
||||
for _, name := range aisearchQueryResolutionOrder {
|
||||
if v := flagValue(cmd, name); v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
@@ -73,9 +73,23 @@ func resolveAisearchKeyword(cmd *cobra.Command) string {
|
||||
}
|
||||
|
||||
func addAisearchPersonFlags(cmd *cobra.Command) {
|
||||
cmd.Flags().StringP("keyword", "w", "", "搜索关键词 (必填,如人名、技能关键词等)")
|
||||
// Alias evidence must be emitted by corecmd.FlagSpec.Aliases. Build the pair
|
||||
// on a temporary declaration command so the legacy alias can retain its
|
||||
// historical -w shorthand without extending the global alias mechanism or
|
||||
// assigning that shorthand to the new --query Primary.
|
||||
declaration := &cobra.Command{Use: "aisearch-query-flags"}
|
||||
corecmd.RegisterFlags(declaration, []corecmd.FlagSpec{{
|
||||
Name: "query",
|
||||
Usage: "搜索关键词 (必填,如人名、技能关键词等)",
|
||||
Aliases: []string{"keyword"},
|
||||
}})
|
||||
queryFlag := declaration.Flags().Lookup("query")
|
||||
keywordFlag := declaration.Flags().Lookup("keyword")
|
||||
keywordFlag.Shorthand = "w"
|
||||
cmd.Flags().AddFlag(queryFlag)
|
||||
cmd.Flags().AddFlag(keywordFlag)
|
||||
cmd.Flags().StringP("dimension", "d", "all", "查询维度: all/name/department/position/duty/supervisor/subordinate/phone/jobNumber,多个用逗号分隔")
|
||||
for _, alias := range aisearchKeywordAliases {
|
||||
for _, alias := range aisearchPersonGuessFlags {
|
||||
cmd.Flags().String(alias, "", "")
|
||||
_ = cmd.Flags().MarkHidden(alias)
|
||||
}
|
||||
@@ -98,8 +112,7 @@ func runAisearchPerson(cmd *cobra.Command, _ []string) error {
|
||||
}
|
||||
keyword := resolveAisearchKeyword(cmd)
|
||||
if keyword == "" {
|
||||
// 复用原有报错文案("keyword is required")
|
||||
return validateRequiredFlags(cmd, "keyword")
|
||||
return validateRequiredFlags(cmd, "query")
|
||||
}
|
||||
dimensions := parseDimensions(flagValue(cmd, "dimension"))
|
||||
return callMCPTool("enterprise_person_search", map[string]any{
|
||||
@@ -188,8 +201,8 @@ func newAisearchCommand() *cobra.Command {
|
||||
Use: "aisearch",
|
||||
Short: "AI 搜问",
|
||||
Long: `AI 搜问:搜索企业人员信息、企业内部知识内容与企业内部行为记录。`,
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --keyword xxx,
|
||||
// 检测到 keyword 就自动等价于 person;否则退回 group help。
|
||||
// 智能 root:模型常漏 person 子命令直接 dws aisearch --query xxx,
|
||||
// 检测到 query 就自动等价于 person;否则退回 group help。
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if resolveAisearchKeyword(cmd) != "" {
|
||||
return runAisearchPerson(cmd, args)
|
||||
@@ -199,9 +212,9 @@ func newAisearchCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --keyword xxx ← root 自己能解析
|
||||
// - dws aisearch person --keyword xxx ← person 本地 flag
|
||||
// - dws aisearch search --keyword xxx ← search 是 person 的 alias
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
// - dws aisearch person --query xxx ← person 本地 flag
|
||||
// - dws aisearch search --query xxx ← search 是 person 的 alias
|
||||
// 不能放在 product PersistentFlags:否则 enterprise/behavior 会公开接受
|
||||
// 它们不拥有的 person-only dimension,破坏 Help ↔ Schema 完整性。
|
||||
addAisearchPersonFlags(root)
|
||||
@@ -219,7 +232,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
"query", "people", "ask", "find", "lookup",
|
||||
"contact",
|
||||
},
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --keyword xxx,
|
||||
// 显式声明允许任意位置参数:模型可能写 dws aisearch person search --query xxx,
|
||||
// 此时 "search" 会作为 positional arg 被忽略,不报错。
|
||||
Args: cobra.ArbitraryArgs,
|
||||
Short: "搜索企业人员",
|
||||
@@ -237,13 +250,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
jobNumber 工号
|
||||
|
||||
多个维度用逗号分隔。`,
|
||||
Example: ` dws aisearch person --keyword "张三" --dimension department
|
||||
dws aisearch person --keyword "产品部" --dimension department
|
||||
dws aisearch person --keyword "五道" --dimension supervisor
|
||||
dws aisearch person --keyword "AI搜问" --dimension duty
|
||||
dws aisearch person --keyword "李四" --dimension name,department
|
||||
dws aisearch person --keyword "13800138000" --dimension phone
|
||||
dws aisearch person --keyword "W12345" --dimension jobNumber`,
|
||||
Example: ` dws aisearch person --query "张三" --dimension department
|
||||
dws aisearch person --query "产品部" --dimension department
|
||||
dws aisearch person --query "五道" --dimension supervisor
|
||||
dws aisearch person --query "AI搜问" --dimension duty
|
||||
dws aisearch person --query "李四" --dimension name,department
|
||||
dws aisearch person --query "13800138000" --dimension phone
|
||||
dws aisearch person --query "W12345" --dimension jobNumber`,
|
||||
RunE: runAisearchPerson,
|
||||
}
|
||||
DeclareLeafMetadata(personCmd, LeafSpec{
|
||||
@@ -277,10 +290,13 @@ func newAisearchCommand() *cobra.Command {
|
||||
"搜企业知识内容时用 aisearch enterprise;搜行为记录时用 aisearch behavior",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws aisearch person --keyword \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --keyword \"五道\" --dimension supervisor --format json",
|
||||
"dws aisearch person --query \"张三\" --dimension name --format json",
|
||||
"dws aisearch person --query \"五道\" --dimension supervisor --format json",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "query", Property: "keyword", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
addAisearchPersonFlags(personCmd)
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
package helpers
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/runtimeannotate"
|
||||
)
|
||||
|
||||
func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
root := newAisearchCommand()
|
||||
@@ -12,12 +17,34 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, name := range []string{"keyword", "dimension"} {
|
||||
for _, name := range []string{"query", "dimension"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want visible local flag", name, flag)
|
||||
}
|
||||
}
|
||||
if person.Flags().Lookup("query").Shorthand != "" {
|
||||
t.Fatal("new Primary --query must not acquire a shorthand")
|
||||
}
|
||||
keyword := person.Flags().Lookup("keyword")
|
||||
if keyword == nil || !keyword.Hidden || keyword.Shorthand != "w" {
|
||||
t.Fatalf("person --keyword = %#v, want hidden legacy flag with -w", keyword)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 1 || got[0] != "query" {
|
||||
t.Fatalf("person --keyword alias_of = %#v, want query", got)
|
||||
}
|
||||
if got := keyword.Annotations[runtimeannotate.AnnotationFlagAliasOrigin]; len(got) != 1 || got[0] != runtimeannotate.FlagAliasOriginCorecmdV1 {
|
||||
t.Fatalf("person --keyword alias_origin = %#v, want %q", got, runtimeannotate.FlagAliasOriginCorecmdV1)
|
||||
}
|
||||
for _, name := range []string{"name", "q", "text"} {
|
||||
flag := person.Flags().Lookup(name)
|
||||
if flag == nil || !flag.Hidden {
|
||||
t.Fatalf("person --%s = %#v, want preserved hidden guess flag", name, flag)
|
||||
}
|
||||
if got := flag.Annotations[runtimeannotate.AnnotationFlagAliasOf]; len(got) != 0 {
|
||||
t.Fatalf("person --%s unexpectedly gained alias_of metadata: %#v", name, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, path := range []string{"enterprise", "behavior"} {
|
||||
cmd, _, err := root.Find([]string{path})
|
||||
@@ -35,3 +62,38 @@ func TestAisearchPersonFlagsDoNotLeakIntoContentCommands(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAisearchPersonQueryPrimaryCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "root new", args: []string{"--query", "new"}, want: "new"},
|
||||
{name: "root old", args: []string{"--keyword", "old"}, want: "old"},
|
||||
{name: "root old shorthand", args: []string{"-w", "old-short"}, want: "old-short"},
|
||||
{name: "root both old wins", args: []string{"--query", "new", "--keyword", "old"}, want: "old"},
|
||||
{name: "person new", args: []string{"person", "--query", "new"}, want: "new"},
|
||||
{name: "person old", args: []string{"person", "--keyword", "old"}, want: "old"},
|
||||
{name: "person old shorthand", args: []string{"person", "-w", "old-short"}, want: "old-short"},
|
||||
{name: "person both old wins", args: []string{"person", "--query", "new", "--keyword", "old"}, want: "old"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
caller := &scriptedToolCaller{}
|
||||
installScriptedCaller(t, caller)
|
||||
if err := executeFilterCoverage(t, newAisearchCommand(), tc.args...); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if caller.tool != "enterprise_person_search" || caller.args["keyword"] != tc.want {
|
||||
t.Fatalf("call = %s %#v, want backend keyword %q", caller.tool, caller.args, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
installScriptedCaller(t, &scriptedToolCaller{})
|
||||
err := executeFilterCoverage(t, newAisearchCommand(), "person")
|
||||
if err == nil || !strings.Contains(err.Error(), "--query") || strings.Contains(err.Error(), "--keyword") {
|
||||
t.Fatalf("missing query error = %v, want only new Primary", err)
|
||||
}
|
||||
}
|
||||
|
||||
+325
-5
@@ -520,6 +520,102 @@ func normalizeFilters(parsed any) any {
|
||||
}
|
||||
}
|
||||
|
||||
type aitableStatsItem struct {
|
||||
FieldID string `json:"fieldId"`
|
||||
StatsType string `json:"statsType"`
|
||||
}
|
||||
|
||||
func aitableStatsValidationf(format string, args ...any) error {
|
||||
return apperrors.NewValidation(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
func parseAitableStatsItems(raw string, uppercase, uniqueFields bool, maxItems int) ([]map[string]any, error) {
|
||||
var items []aitableStatsItem
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 必须是 JSON 数组: %v", err)
|
||||
}
|
||||
var strictItems []aitableStatsItem
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&strictItems); err != nil {
|
||||
return nil, aitableStatsValidationf("--stats 包含不支持的字段: %v", err)
|
||||
}
|
||||
items = strictItems
|
||||
if len(items) == 0 {
|
||||
return nil, aitableStatsValidationf("--stats 至少需要一个统计项")
|
||||
}
|
||||
if maxItems > 0 && len(items) > maxItems {
|
||||
return nil, aitableStatsValidationf("--stats 单次最多支持 %d 个统计项,got %d", maxItems, len(items))
|
||||
}
|
||||
|
||||
seenFields := make(map[string]struct{}, len(items))
|
||||
out := make([]map[string]any, 0, len(items))
|
||||
for index, item := range items {
|
||||
item.FieldID = strings.TrimSpace(item.FieldID)
|
||||
item.StatsType = strings.TrimSpace(item.StatsType)
|
||||
if item.FieldID == "" || item.StatsType == "" {
|
||||
return nil, aitableStatsValidationf("--stats[%d] 的 fieldId 和 statsType 均不能为空", index)
|
||||
}
|
||||
if uniqueFields {
|
||||
if _, exists := seenFields[item.FieldID]; exists {
|
||||
return nil, aitableStatsValidationf("--stats 中 fieldId %q 重复;query_records_stats 要求同一字段的多个统计类型拆成多次调用", item.FieldID)
|
||||
}
|
||||
seenFields[item.FieldID] = struct{}{}
|
||||
}
|
||||
if uppercase && item.StatsType != strings.ToUpper(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用大写枚举值,got %q", index, item.StatsType)
|
||||
}
|
||||
if !uppercase && item.StatsType != strings.ToLower(item.StatsType) {
|
||||
return nil, aitableStatsValidationf("--stats[%d].statsType 必须使用小写值,got %q", index, item.StatsType)
|
||||
}
|
||||
out = append(out, map[string]any{"fieldId": item.FieldID, "statsType": item.StatsType})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseAitableObjectFlag(name, raw string) (map[string]any, error) {
|
||||
decoder := json.NewDecoder(strings.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value map[string]any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象: %v", name, err)
|
||||
}
|
||||
if value == nil {
|
||||
return nil, aitableStatsValidationf("--%s 必须是 JSON 对象,不能是 null", name)
|
||||
}
|
||||
var trailing any
|
||||
if err := decoder.Decode(&trailing); err != io.EOF {
|
||||
if err == nil {
|
||||
return nil, aitableStatsValidationf("--%s 只能包含一个 JSON 对象", name)
|
||||
}
|
||||
return nil, aitableStatsValidationf("--%s 包含无效的尾随内容: %v", name, err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func validateAitableStatsFilters(value map[string]any, rawJSON string) error {
|
||||
if err := validateFiltersStructure(value, rawJSON); err != nil {
|
||||
return aitableStatsValidationf("%v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAitableArrayDSL(name, raw string) error {
|
||||
var value []any
|
||||
if err := json.Unmarshal([]byte(raw), &value); err != nil {
|
||||
return aitableStatsValidationf("--%s 必须是 JSON 数组字符串: %v", name, err)
|
||||
}
|
||||
if len(value) == 0 {
|
||||
return aitableStatsValidationf("--%s 至少需要一个条目", name)
|
||||
}
|
||||
for index, item := range value {
|
||||
if _, ok := item.(map[string]any); !ok {
|
||||
return aitableStatsValidationf("--%s[%d] 必须是 JSON 对象,got %T", name, index, item)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeViewConfigFilter 将 view config 中的 filter 字段规范化为服务端要求的数组格式。
|
||||
// 服务端 POJO 要求 config.filter 为 []FilterRule(JSON array)。
|
||||
// 常见错误格式:
|
||||
@@ -695,15 +791,29 @@ const aitableMaxRetries = 3
|
||||
// callAitableTool 是 aitable 专用的 MCP 调用入口,带自动重试。
|
||||
// 替代直接调用 callMCPTool,对网络抖动和服务端瞬态错误进行透明重试。
|
||||
func callAitableTool(toolName string, args map[string]any) error {
|
||||
return callAitableToolContext(context.Background(), toolName, args)
|
||||
}
|
||||
|
||||
func callAitableToolContext(ctx context.Context, toolName string, args map[string]any) error {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
var lastErr error
|
||||
for attempt := 0; attempt <= aitableMaxRetries; attempt++ {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if attempt > 0 {
|
||||
backoff := time.Duration(1<<(attempt-1)) * time.Second // 1s, 2s, 4s
|
||||
fmt.Fprintf(os.Stderr, "[aitable retry %d/%d] %s after %v...\n", attempt, aitableMaxRetries, toolName, backoff)
|
||||
helperSleep(backoff)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-helperAfter(backoff):
|
||||
}
|
||||
}
|
||||
|
||||
err := callMCPTool(toolName, args)
|
||||
err := callMCPToolContext(ctx, toolName, args)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1098,7 +1208,7 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable base [list|search|get|get-primary-doc-id|create|update|delete|copy] Base 管理
|
||||
dws aitable table [get|create|update|delete] 数据表管理
|
||||
dws aitable field [get|create|update|delete|search-options] 字段管理
|
||||
dws aitable record [query|create|update|delete] 记录管理
|
||||
dws aitable record [query|stats|group-stats|create|update|delete] 记录管理
|
||||
dws aitable view [get|create|update|delete] 视图管理
|
||||
dws aitable form [list|delete|update] 表单管理
|
||||
dws aitable form field [list|update|hide] 表单字段管理
|
||||
@@ -1781,7 +1891,7 @@ config 结构参考:
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
return callAitableTool("get_fields", toolArgs)
|
||||
return callAitableToolContext(cmd.Context(), "get_fields", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(fieldGetCmd, LeafSpec{
|
||||
@@ -2245,6 +2355,199 @@ newFieldName、config、aiConfig 至少传入一项。
|
||||
},
|
||||
})
|
||||
|
||||
recordStatsCmd := &cobra.Command{
|
||||
Use: "stats",
|
||||
Short: "整表或过滤后的字段聚合统计",
|
||||
Long: `对单张数据表执行不分组的服务端聚合,底层调用 query_records_stats。
|
||||
适用于记录计数、求和、平均值、最大值、最小值、中位数、去重数、完整率等标量统计。
|
||||
|
||||
--stats 是 JSON 数组,单次最多 20 项;每项必须包含 fieldId 和大写 statsType。
|
||||
同一个 fieldId 不能在一次请求中重复,如需对同一字段计算多个指标,请拆成多次调用。
|
||||
常用 statsType:COUNT、COUNT_COLUMN、SUM、AVG、MAX、MIN、MEDIAN、STANDARD_DEVIATION、RANGE、
|
||||
DISTINCT、DISTINCT_RATIO、EXISTS、UN_EXISTS、EXIST_RATIO、UN_EXIST_RATIO、CHECKED、UN_CHECKED、
|
||||
CHECKED_RATIO、UN_CHECKED_RATIO、LATEST_DATE、EARLIEST_DATE、DATE_RANGE、DATE_RANGE_MONTH。
|
||||
|
||||
需要统计全部匹配记录时不要传 --limit;limit 会改变参与统计的记录范围。
|
||||
lt/gt/lte/gte 的过滤值必须使用 JSON 数字;单选/多选过滤建议使用 field get 返回的选项 ID。`,
|
||||
Example: ` dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldAmount","statsType":"SUM"}]'
|
||||
dws aitable record stats --base-id BASE_ID --table-id TABLE_ID --filters '{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}' --stats '[{"fieldId":"fldTitle","statsType":"COUNT"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), true, true, 20)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
// query_records_stats 的 MCP Schema 将 sort 定义为 JSON 数组编码后的字符串,
|
||||
// 与 query_records 使用的数组类型不同。
|
||||
toolArgs["sort"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value <= 0 {
|
||||
return aitableStatsValidationf("--limit 必须大于 0;统计全部匹配记录时请省略该参数")
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("keyword"); value != "" {
|
||||
toolArgs["keyword"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("search-field-ids"); value != "" {
|
||||
toolArgs["searchFieldIds"] = parseCSVValues(value)
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_records_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_records_stats",
|
||||
CanonicalPath: "aitable.query_records_stats",
|
||||
CLIPath: "aitable record stats",
|
||||
PrimaryCLIPath: "aitable record stats",
|
||||
},
|
||||
Description: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
Interface: aitableMCPInterface("query_records_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对整表或过滤后的记录执行不分组字段聚合。",
|
||||
UseWhen: []string{"需要记录总数、SUM/AVG/MAX/MIN、中位数、去重数、完整率等标量统计时,优先服务端聚合"},
|
||||
AvoidWhen: []string{"需要按字段分组或使用 query_stats 高级聚合时用 record group-stats;需要行级明细时用 record query"},
|
||||
Examples: []string{`dws aitable record stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"COUNT"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "search-field-ids", Property: "searchFieldIds", InterfaceType: "array"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableRecordsStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordGroupStatsCmd := &cobra.Command{
|
||||
Use: "group-stats",
|
||||
Short: "分组、去重及高级聚合统计",
|
||||
Long: `对单张数据表执行分组或高级服务端聚合,底层调用 query_stats,最多返回 1000 个分组。
|
||||
适用于按一个或多个字段分组、条件唯一实体计数,以及 distinct、distinct_ratio 等高级统计。
|
||||
|
||||
--stats 是 JSON 数组,每项包含 fieldId 和小写 statsType。基础类型为 sum、avg、count、max、min;
|
||||
部分后端也支持 median、distinct、distinct_ratio 等高级类型,不支持时服务端会返回明确错误。
|
||||
--group 是 JSON 数组编码后的字符串,不分组的 DISTINCT 标量统计可以省略。
|
||||
不要依赖服务端 limit 选择 Top N;应在不超过 1000 个分组时获取完整结果后再排序。`,
|
||||
Example: ` dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --group '[{"fieldId":"fldCategory","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]' --stats '[{"fieldId":"fldAmount","statsType":"sum"}]'
|
||||
dws aitable record group-stats --base-id BASE_ID --table-id TABLE_ID --stats '[{"fieldId":"fldStore","statsType":"distinct"}]'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "stats"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stats, err := parseAitableStatsItems(mustGetFlag(cmd, "stats"), false, false, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"stats": stats,
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("filters"); raw != "" {
|
||||
filters, err := parseAitableObjectFlag("filters", raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateAitableStatsFilters(filters, raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["filters"] = normalizeFilters(filters)
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("group"); raw != "" {
|
||||
if err := validateAitableArrayDSL("group", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["group"] = raw
|
||||
}
|
||||
if raw, _ := cmd.Flags().GetString("sort"); raw != "" {
|
||||
if err := validateAitableArrayDSL("sort", raw); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sortDsl"] = raw
|
||||
}
|
||||
if value, _ := cmd.Flags().GetInt("limit"); cmd.Flags().Changed("limit") {
|
||||
if value < 1 || value > 1000 {
|
||||
return aitableStatsValidationf("--limit 必须在 [1, 1000] 范围内,got %d", value)
|
||||
}
|
||||
toolArgs["limit"] = value
|
||||
}
|
||||
if value, _ := cmd.Flags().GetString("data-version"); value != "" {
|
||||
toolArgs["dataVersion"] = value
|
||||
}
|
||||
return callAitableTool("query_stats", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(recordGroupStatsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "query_stats",
|
||||
CanonicalPath: "aitable.query_stats",
|
||||
CLIPath: "aitable record group-stats",
|
||||
PrimaryCLIPath: "aitable record group-stats",
|
||||
},
|
||||
Description: "按字段分组,或执行去重等高级聚合统计。",
|
||||
Interface: aitableMCPInterface("query_stats"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按字段分组,或执行去重等高级聚合统计。",
|
||||
UseWhen: []string{"需要各分类/实体的分组统计,或对满足条件的门店、客户、商品等执行 DISTINCT 唯一计数时"},
|
||||
AvoidWhen: []string{"普通不分组 COUNT/SUM/AVG/MAX/MIN 优先用 record stats;原始记录和 Top N 明细用 record query"},
|
||||
Examples: []string{`dws aitable record group-stats --base-id <BASE_ID> --table-id <TABLE_ID> --stats '[{"fieldId":"<FIELD_ID>","statsType":"distinct"}]'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "stats", Property: "stats", Required: boolPtr(true), InterfaceType: "array"},
|
||||
{Name: "filters", Property: "filters", InterfaceType: "object"},
|
||||
{Name: "group", Property: "group"},
|
||||
{Name: "sort", Property: "sortDsl"},
|
||||
{Name: "data-version", Property: "dataVersion"},
|
||||
},
|
||||
Result: aitableGroupedStatsResultSpec(),
|
||||
},
|
||||
})
|
||||
|
||||
recordCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "新增记录",
|
||||
@@ -7230,6 +7533,23 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordQueryCmd.Flags().Int("page-limit", 50, "自动翻页最大页数(仅 --all 时生效)。默认 50 页(约 5000 条);设为 0 表示显式不限页数;超限时错误详情保留已取记录和续传 cursor")
|
||||
recordQueryCmd.Flags().String("view-id", "", "视图 ID(record query 不支持按视图过滤,此参数会被忽略并给出提示)")
|
||||
_ = recordQueryCmd.Flags().MarkHidden("view-id")
|
||||
recordStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"SUM"}];statsType 必须大写,最多 20 项且 fieldId 不得重复`)
|
||||
recordStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordStatsCmd.Flags().String("sort", "", `参与统计记录的排序 DSL(JSON 数组字符串),例如 [{"fieldId":"fldDate","direction":"ASC"}]`)
|
||||
recordStatsCmd.Flags().Int("limit", 0, "参与统计的最大记录数;省略表示统计全部匹配记录")
|
||||
recordStatsCmd.Flags().String("keyword", "", "全文关键词,仅匹配的记录参与统计")
|
||||
recordStatsCmd.Flags().String("search-field-ids", "", "关键词搜索字段 ID 列表,逗号分隔;仅 --keyword 非空时生效")
|
||||
recordStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordGroupStatsCmd.Flags().String("base-id", "", "Base ID(通过 base get 确认目标)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("table-id", "", "Table ID(通过 table get 获取)(必填)")
|
||||
recordGroupStatsCmd.Flags().String("stats", "", `统计项 JSON 数组(必填),例如 [{"fieldId":"fldAmount","statsType":"sum"}];statsType 必须小写`)
|
||||
recordGroupStatsCmd.Flags().String("filters", "", "结构化过滤条件 JSON 对象;数值比较值必须是 JSON 数字")
|
||||
recordGroupStatsCmd.Flags().String("group", "", `分组字段 DSL(JSON 数组字符串);不分组的 distinct 统计可省略`)
|
||||
recordGroupStatsCmd.Flags().String("sort", "", "统计结果排序 DSL(JSON 数组字符串),映射到 MCP sortDsl")
|
||||
recordGroupStatsCmd.Flags().Int("limit", 0, "返回的分组结果数,范围 1-1000;省略表示不额外限制")
|
||||
recordGroupStatsCmd.Flags().String("data-version", "", "可选数据版本;通常省略以使用最新版本")
|
||||
recordCreateCmd.Flags().String("base-id", "", "Base ID,可通过 base list 或 base search 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("table-id", "", "Table ID,可通过 base get 获取 (必填)")
|
||||
recordCreateCmd.Flags().String("records", "", "待创建的记录列表 JSON 数组,单次最多 100 条 (必填)")
|
||||
@@ -7294,7 +7614,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
recordPrimaryDocCreateCmd.Flags().String("record-id", "", "目标 Record ID (必填)")
|
||||
|
||||
recordCmd.AddCommand(
|
||||
recordQueryCmd, recordCreateCmd,
|
||||
recordQueryCmd, recordStatsCmd, recordGroupStatsCmd, recordCreateCmd,
|
||||
recordUpdateCmd, recordDeleteCmd,
|
||||
recordBatchUpdateCmd,
|
||||
recordHistoryListCmd,
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,22 @@ type aitableCommandCoverageCaller struct {
|
||||
response map[string]string
|
||||
}
|
||||
|
||||
type aitableCommandContextKey struct{}
|
||||
|
||||
type aitableCommandContextCaller struct {
|
||||
value any
|
||||
}
|
||||
|
||||
func (c *aitableCommandContextCaller) CallTool(ctx context.Context, _, _ string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
c.value = ctx.Value(aitableCommandContextKey{})
|
||||
return nil, context.Canceled
|
||||
}
|
||||
|
||||
func (*aitableCommandContextCaller) Format() string { return "json" }
|
||||
func (*aitableCommandContextCaller) DryRun() bool { return false }
|
||||
func (*aitableCommandContextCaller) Fields() string { return "" }
|
||||
func (*aitableCommandContextCaller) JQ() string { return "" }
|
||||
|
||||
func (c *aitableCommandCoverageCaller) CallTool(_ context.Context, _, tool string, _ map[string]any) (*edition.ToolResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
@@ -67,6 +84,11 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
|
||||
oldDeps, oldSleep := deps, helperSleep
|
||||
t.Cleanup(func() { deps, helperSleep = oldDeps, oldSleep })
|
||||
helperSleep = func(time.Duration) {}
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
ready := make(chan time.Time, 1)
|
||||
ready <- time.Time{}
|
||||
return ready
|
||||
})
|
||||
|
||||
retryable := fmt.Errorf("timeout: retryable: true")
|
||||
caller := &aitableTestCaller{errors: []error{retryable, retryable, retryable, retryable}}
|
||||
@@ -86,6 +108,48 @@ func TestCrossPlatformCoverageAitableRetryWrappersExhaustAndRecover(t *testing.T
|
||||
if err := callAitableHelperTool("retry", nil); err == nil {
|
||||
t.Fatal("exhausted helper retries returned nil")
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
if err := callAitableToolContext(nil, "nil-context", nil); err != nil {
|
||||
t.Fatalf("nil context was not normalized: %v", err)
|
||||
}
|
||||
|
||||
caller = &aitableTestCaller{errors: []error{retryable}}
|
||||
installAitableDeps(t, caller)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
backoffPending := make(chan time.Time)
|
||||
testseam.Swap(t, &helperAfter, func(time.Duration) <-chan time.Time {
|
||||
cancel()
|
||||
return backoffPending
|
||||
})
|
||||
if err := callAitableToolContext(ctx, "cancel-during-backoff", nil); err != context.Canceled {
|
||||
t.Fatalf("cancel during retry backoff = %v, want %v", err, context.Canceled)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableFieldListPreservesCommandContext(t *testing.T) {
|
||||
old := deps
|
||||
t.Cleanup(func() { deps = old })
|
||||
caller := &aitableCommandContextCaller{}
|
||||
InitDeps(caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
|
||||
root := newAitableCommand()
|
||||
installExampleGlobalFlags(root)
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"field", "list", "--base-id=b", "--table-id=t"})
|
||||
ctx := context.WithValue(context.Background(), aitableCommandContextKey{}, "field-list-context")
|
||||
if err := root.ExecuteContext(ctx); err == nil {
|
||||
t.Fatal("field list context probe unexpectedly succeeded")
|
||||
}
|
||||
if caller.value != "field-list-context" {
|
||||
t.Fatalf("field list caller context value = %#v", caller.value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableCommandValidationEdges(t *testing.T) {
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
@@ -72,3 +74,102 @@ func aitableCompositeInterface(reason string) *contract.InterfaceSpec {
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func aitableRecordsStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"不分组的字段聚合结果",
|
||||
"properties":{
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"按数据版本返回的聚合结果批次",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"deltaVersion":{"description":"聚合结果的增量版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"请求中各统计项的结果",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"被统计的字段 ID"},
|
||||
"statsType":{"type":"string","description":"实际执行的统计类型"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计类型和字段类型"}
|
||||
},
|
||||
"required":["fieldId","statsType","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
func aitableGroupedStatsResultSpec() *contract.ResultSpec {
|
||||
return &contract.ResultSpec{
|
||||
Outcomes: []contract.ResultOutcome{
|
||||
contract.ResultOutcomeSuccess,
|
||||
contract.ResultOutcomeFailure,
|
||||
},
|
||||
DataSchema: json.RawMessage(`{
|
||||
"type":"object",
|
||||
"description":"分组或高级字段聚合结果",
|
||||
"properties":{
|
||||
"dataVersion":{"description":"参与统计的数据版本"},
|
||||
"results":{
|
||||
"type":"array",
|
||||
"description":"每个分组一条结果;未分组时通常只有一条",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"groupKeys":{
|
||||
"type":"array",
|
||||
"description":"当前结果的分组键",
|
||||
"items":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"fieldId":{"type":"string","description":"分组字段 ID"},
|
||||
"value":{"description":"分组值;编码取决于字段类型"},
|
||||
"recordCount":{"type":"integer","description":"该分组包含的记录数"}
|
||||
},
|
||||
"additionalProperties":true
|
||||
}
|
||||
},
|
||||
"fieldStatsMap":{
|
||||
"type":"object",
|
||||
"description":"按字段 ID 索引的聚合值",
|
||||
"additionalProperties":{
|
||||
"type":"object",
|
||||
"properties":{
|
||||
"action":{"type":"string","description":"实际执行的统计动作"},
|
||||
"value":{"description":"统计值;具体 JSON 类型取决于统计动作和字段类型"}
|
||||
},
|
||||
"required":["action","value"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["fieldStatsMap"],
|
||||
"additionalProperties":true
|
||||
}
|
||||
}
|
||||
},
|
||||
"required":["results"],
|
||||
"additionalProperties":true
|
||||
}`),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contractfinal"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
func runAitableStatsCLI(t *testing.T, args ...string) (*aitableTestCaller, error) {
|
||||
t.Helper()
|
||||
caller := &aitableTestCaller{}
|
||||
installAitableDeps(t, caller)
|
||||
command := newAitableCommand()
|
||||
command.SilenceErrors = true
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(args)
|
||||
return caller, command.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableRecordsStatsForwardsValidatedContract(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldCount","statsType":"COUNT"},{"fieldId":"fldAmount","statsType":"SUM"}]`,
|
||||
"--filters", `{"operator":"and","operands":[{"operator":"gt","operands":["fldAmount",0]}]}`,
|
||||
"--sort", `[{"fieldId":"fldAmount","direction":"DESC"}]`,
|
||||
"--limit", "500",
|
||||
"--keyword", "华东",
|
||||
"--search-field-ids", "fldName,fldRegion",
|
||||
"--data-version", "42",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_records_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_records_stats", call.server, call.tool)
|
||||
}
|
||||
wantStats := []map[string]any{
|
||||
{"fieldId": "fldCount", "statsType": "COUNT"},
|
||||
{"fieldId": "fldAmount", "statsType": "SUM"},
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["stats"], wantStats) {
|
||||
t.Fatalf("stats = %#v, want %#v", call.args["stats"], wantStats)
|
||||
}
|
||||
filters := call.args["filters"].(map[string]any)
|
||||
condition := filters["operands"].([]any)[0].(map[string]any)
|
||||
if got := condition["operands"].([]any)[1]; got != json.Number("0") {
|
||||
t.Fatalf("numeric filter value = %#v (%T), want json.Number(0)", got, got)
|
||||
}
|
||||
if call.args["sort"] != `[{"fieldId":"fldAmount","direction":"DESC"}]` ||
|
||||
call.args["limit"] != 500 || call.args["keyword"] != "华东" || call.args["dataVersion"] != "42" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
if !reflect.DeepEqual(call.args["searchFieldIds"], []string{"fldName", "fldRegion"}) {
|
||||
t.Fatalf("searchFieldIds = %#v", call.args["searchFieldIds"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableGroupStatsForwardsStringDSL(t *testing.T) {
|
||||
group := `[{"fieldId":"fldRegion","direction":"ASC","fieldConfig":null,"arraySplitMode":true}]`
|
||||
sortDSL := `[{"fieldId":"fldAmount","direction":"DESC"}]`
|
||||
caller, err := runAitableStatsCLI(t,
|
||||
"record", "group-stats",
|
||||
"--base-id", "base-stats",
|
||||
"--table-id", "table-stats",
|
||||
"--stats", `[{"fieldId":"fldStore","statsType":"distinct"},{"fieldId":"fldAmount","statsType":"avg"}]`,
|
||||
"--filters", `{"operator":"OR","operands":[{"operator":"not_before","operands":["fldDate","2026-01-01"]}]}`,
|
||||
"--group", group,
|
||||
"--sort", sortDSL,
|
||||
"--limit", "1000",
|
||||
"--data-version", "43",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("record group-stats returned error: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("tool calls = %d, want 1", len(caller.calls))
|
||||
}
|
||||
call := caller.calls[0]
|
||||
if call.server != "aitable" || call.tool != "query_stats" {
|
||||
t.Fatalf("tool = %s/%s, want aitable/query_stats", call.server, call.tool)
|
||||
}
|
||||
if call.args["group"] != group || call.args["sortDsl"] != sortDSL || call.args["limit"] != 1000 || call.args["dataVersion"] != "43" {
|
||||
t.Fatalf("forwarded args = %#v", call.args)
|
||||
}
|
||||
stats := call.args["stats"].([]map[string]any)
|
||||
if len(stats) != 2 || stats[0]["statsType"] != "distinct" || stats[1]["statsType"] != "avg" {
|
||||
t.Fatalf("stats = %#v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsRejectsInvalidInputsBeforeCallingMCP(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "records stats require uppercase",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`},
|
||||
wantErr: "大写",
|
||||
},
|
||||
{
|
||||
name: "records stats require at least one item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[]`},
|
||||
wantErr: "至少需要一个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats enforce item cap",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[
|
||||
{"fieldId":"f01","statsType":"COUNT"},{"fieldId":"f02","statsType":"COUNT"},
|
||||
{"fieldId":"f03","statsType":"COUNT"},{"fieldId":"f04","statsType":"COUNT"},
|
||||
{"fieldId":"f05","statsType":"COUNT"},{"fieldId":"f06","statsType":"COUNT"},
|
||||
{"fieldId":"f07","statsType":"COUNT"},{"fieldId":"f08","statsType":"COUNT"},
|
||||
{"fieldId":"f09","statsType":"COUNT"},{"fieldId":"f10","statsType":"COUNT"},
|
||||
{"fieldId":"f11","statsType":"COUNT"},{"fieldId":"f12","statsType":"COUNT"},
|
||||
{"fieldId":"f13","statsType":"COUNT"},{"fieldId":"f14","statsType":"COUNT"},
|
||||
{"fieldId":"f15","statsType":"COUNT"},{"fieldId":"f16","statsType":"COUNT"},
|
||||
{"fieldId":"f17","statsType":"COUNT"},{"fieldId":"f18","statsType":"COUNT"},
|
||||
{"fieldId":"f19","statsType":"COUNT"},{"fieldId":"f20","statsType":"COUNT"},
|
||||
{"fieldId":"f21","statsType":"COUNT"}
|
||||
]`},
|
||||
wantErr: "单次最多支持 20 个统计项",
|
||||
},
|
||||
{
|
||||
name: "records stats require item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":" ","statsType":"COUNT"}]`},
|
||||
wantErr: "均不能为空",
|
||||
},
|
||||
{
|
||||
name: "records stats reject unknown item fields",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT","unknownKey":123}]`},
|
||||
wantErr: "不支持的字段",
|
||||
},
|
||||
{
|
||||
name: "records stats reject duplicate field",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"},{"fieldId":"f","statsType":"SUM"}]`},
|
||||
wantErr: "重复",
|
||||
},
|
||||
{
|
||||
name: "group stats require lowercase",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`},
|
||||
wantErr: "小写",
|
||||
},
|
||||
{
|
||||
name: "group must be array DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--group", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
{
|
||||
name: "group limit capped",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--limit", "1001"},
|
||||
wantErr: "[1, 1000]",
|
||||
},
|
||||
{
|
||||
name: "filters require logical root",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject malformed JSON",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject null",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `null`},
|
||||
wantErr: "不能是 null",
|
||||
},
|
||||
{
|
||||
name: "records filters reject a second JSON value",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} {}`},
|
||||
wantErr: "只能包含一个 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records filters reject invalid trailing content",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{} trailing`},
|
||||
wantErr: "无效的尾随内容",
|
||||
},
|
||||
{
|
||||
name: "records filters require operand array",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":{}}`},
|
||||
wantErr: `"operands" must be an array`,
|
||||
},
|
||||
{
|
||||
name: "records filters reject silently unsupported operators",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "records sort requires an item",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[]`},
|
||||
wantErr: "至少需要一个条目",
|
||||
},
|
||||
{
|
||||
name: "records sort items must be objects",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--sort", `[1]`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "records limit must be positive",
|
||||
args: []string{"record", "stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"COUNT"}]`, "--limit", "0"},
|
||||
wantErr: "必须大于 0",
|
||||
},
|
||||
{
|
||||
name: "group filters reject malformed JSON",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{`},
|
||||
wantErr: "必须是 JSON 对象",
|
||||
},
|
||||
{
|
||||
name: "group filters require logical root",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"eq","operands":[]}`},
|
||||
wantErr: `root "operator" must be "and" or "or"`,
|
||||
},
|
||||
{
|
||||
name: "group filters reject silently unsupported operators",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--filters", `{"operator":"and","operands":[{"operator":"date_between","operands":["fldDate",[1,2]]}]}`},
|
||||
wantErr: `unsupported filter operator "date_between"`,
|
||||
},
|
||||
{
|
||||
name: "group sort rejects invalid DSL",
|
||||
args: []string{"record", "group-stats", "--base-id", "b", "--table-id", "t", "--stats", `[{"fieldId":"f","statsType":"count"}]`, "--sort", `{}`},
|
||||
wantErr: "JSON 数组",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller, err := runAitableStatsCLI(t, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want containing %q", err, test.wantErr)
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("error = %#v, want structured validation error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("tool calls = %d, want 0", len(caller.calls))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAitableStatsPublishInterfaceAndResultContracts(t *testing.T) {
|
||||
root := newAitableCommand()
|
||||
tests := []struct {
|
||||
path string
|
||||
rpc string
|
||||
}{
|
||||
{path: "aitable record stats", rpc: "query_records_stats"},
|
||||
{path: "aitable record group-stats", rpc: "query_stats"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
leaf := findCLIPath(root, test.path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("missing leaf %q", test.path)
|
||||
}
|
||||
final, ok := contractfinal.RuntimeContractFinal(leaf)
|
||||
if !ok || final.Interface == nil || final.Interface.Ref == nil || final.Interface.Ref.RPCName != test.rpc {
|
||||
t.Fatalf("%s interface = %#v", test.path, final.Interface)
|
||||
}
|
||||
if final.Result == nil || len(final.Result.Outcomes) == 0 || len(final.Result.DataSchema) == 0 {
|
||||
t.Fatalf("%s result = %#v", test.path, final.Result)
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user