Compare commits
371
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65ab48ae5a | ||
|
|
0dc21e8c36 | ||
|
|
b45eba0f5c | ||
|
|
c629e1e3eb | ||
|
|
aeb1b2ced2 | ||
|
|
e3124ccea1 | ||
|
|
5605821a70 | ||
|
|
3fd0d97a26 | ||
|
|
71556a19d9 | ||
|
|
69f1337316 | ||
|
|
01476727e9 | ||
|
|
61ffc988c5 | ||
|
|
156be4cee7 | ||
|
|
2a42e282c7 | ||
|
|
f5378d2f28 | ||
|
|
ea4a463956 | ||
|
|
8d34acbb38 | ||
|
|
9401f9921a | ||
|
|
ed951ff0e1 | ||
|
|
b42596214d | ||
|
|
f684c412ea | ||
|
|
a4813f78bc | ||
|
|
415d962371 | ||
|
|
26253a4eeb | ||
|
|
46fdaed90b | ||
|
|
9ea19315e9 | ||
|
|
8549a90402 | ||
|
|
a5dbd2e049 | ||
|
|
0ebdcb455d | ||
|
|
b1a6f9e20c | ||
|
|
29a0dde9d4 | ||
|
|
0fae9dd8b3 | ||
|
|
6096227511 | ||
|
|
aa265acd50 | ||
|
|
7210e5677c | ||
|
|
1c90623e31 | ||
|
|
e7236e27d4 | ||
|
|
e73ebab2db | ||
|
|
1b6a592387 | ||
|
|
ce7f66ff8e | ||
|
|
e58f255476 | ||
|
|
79f4f66d34 | ||
|
|
66061bb0b0 | ||
|
|
1d14258121 | ||
|
|
cbbc9a9f90 | ||
|
|
723d43f660 | ||
|
|
12d60d264d | ||
|
|
7bb2019dfe | ||
|
|
9e692ce0bf | ||
|
|
e2b390217a | ||
|
|
09ecaaa7a1 | ||
|
|
95ecc99c41 | ||
|
|
6df6b3f8dd | ||
|
|
227861a71d | ||
|
|
20a01aaeff | ||
|
|
7844b59a8c | ||
|
|
2a875eee1d | ||
|
|
c75eda1a2b | ||
|
|
662352202c | ||
|
|
fa2d54fd42 | ||
|
|
c5d58ec49d | ||
|
|
adbc9aac3e | ||
|
|
82a87bf9ce | ||
|
|
0bfb1ff1de | ||
|
|
426ad50927 | ||
|
|
b57cecb62d | ||
|
|
8dc52d052c | ||
|
|
f8e3f0b12a | ||
|
|
aab67202a0 | ||
|
|
2e60b8e121 | ||
|
|
6147021ae8 | ||
|
|
f84fc1d684 | ||
|
|
ae31d371de | ||
|
|
c20a53cd8a | ||
|
|
2dc52afd60 | ||
|
|
21bca3025f | ||
|
|
874b5b091a | ||
|
|
1bb56364c2 | ||
|
|
cd93b7fdc2 | ||
|
|
758b0f875e | ||
|
|
fdb5fc19f5 | ||
|
|
583d31141d | ||
|
|
19be571574 | ||
|
|
38360969e7 | ||
|
|
f9277ae1d3 | ||
|
|
232d9dee6e | ||
|
|
c2528f1fc8 | ||
|
|
1b22b79fa9 | ||
|
|
f3e98d55e8 | ||
|
|
b5a287ae71 | ||
|
|
da6f867dfa | ||
|
|
82dc2b5e5e | ||
|
|
9265fd4cb8 | ||
|
|
e324ef9d4a | ||
|
|
fb1847d62e | ||
|
|
331681e82b | ||
|
|
1633888290 | ||
|
|
206f33ae1c | ||
|
|
9259477372 | ||
|
|
137151b38c | ||
|
|
081220f15e | ||
|
|
b8216380de | ||
|
|
83bc213b7f | ||
|
|
804b9a3142 | ||
|
|
cfdb0d0556 | ||
|
|
d8686122ab | ||
|
|
222a0230ae | ||
|
|
f7befc7943 | ||
|
|
1dc924af1b | ||
|
|
0e8d6e00cf | ||
|
|
0028ed1570 | ||
|
|
8b5d9a59b0 | ||
|
|
01d663f597 | ||
|
|
2bf314c625 | ||
|
|
8c98d1abe4 | ||
|
|
5e4a65513b | ||
|
|
4ae0e0ffc3 | ||
|
|
1c9a977d08 | ||
|
|
78fabec4bb | ||
|
|
6d6404993a | ||
|
|
008d50bb3d | ||
|
|
f871689960 | ||
|
|
b15a21de93 | ||
|
|
58e8e35948 | ||
|
|
a8b0d8895b | ||
|
|
546c2d2eb2 | ||
|
|
5bd0ea7c53 | ||
|
|
4833b39071 | ||
|
|
8a4e49dbf2 | ||
|
|
7c924c54ca | ||
|
|
59d0b6dd75 | ||
|
|
74f7bbc980 | ||
|
|
2b7d5a2c5f | ||
|
|
fcfead71cb | ||
|
|
0beb1c6b0c | ||
|
|
3b38d4c8da | ||
|
|
d68e340a5b | ||
|
|
98799effba | ||
|
|
9239f9070a | ||
|
|
202c5ce697 | ||
|
|
8ab2ac5e7c | ||
|
|
b85a342e9f | ||
|
|
ad72cf4b3d | ||
|
|
89154b3952 | ||
|
|
b01febf52e | ||
|
|
74b7690cbb | ||
|
|
8312c4f30e | ||
|
|
35c6fd95e1 | ||
|
|
564ff8563f | ||
|
|
c7510cd1a1 | ||
|
|
1c3477c087 | ||
|
|
93d450a9bf | ||
|
|
cf39768095 | ||
|
|
c096258b0f | ||
|
|
5ba8ac6775 | ||
|
|
66fee5ef5f | ||
|
|
d9b9c5c7da | ||
|
|
684411e54e | ||
|
|
de5ba029d4 | ||
|
|
15f139e32d | ||
|
|
768c1ce494 | ||
|
|
966fd60e2f | ||
|
|
7825c3c7e0 | ||
|
|
10d44615d1 | ||
|
|
1e88612e43 | ||
|
|
5934ccac7f | ||
|
|
d9365f3fff | ||
|
|
15d93698bd | ||
|
|
7e69a3d6fe | ||
|
|
94b4958038 | ||
|
|
97a99ba04f | ||
|
|
5e1e5cbb84 | ||
|
|
e04e886c50 | ||
|
|
1acde9b766 | ||
|
|
852efe56aa | ||
|
|
6c287bcb4d | ||
|
|
df24d53886 | ||
|
|
49cecabb12 | ||
|
|
09993ad82c | ||
|
|
0efaf6c82f | ||
|
|
26002637f4 | ||
|
|
f7229091ae | ||
|
|
77aa813467 | ||
|
|
1f595571c0 | ||
|
|
cd90d1c322 | ||
|
|
49ab53ea0d | ||
|
|
78433198fb | ||
|
|
4863152a4a | ||
|
|
2057fec3b0 | ||
|
|
1308d08862 | ||
|
|
8b56e9bc9e | ||
|
|
87e141f2de | ||
|
|
9b521f0392 | ||
|
|
4dcd528bc1 | ||
|
|
0bbb3a9d32 | ||
|
|
0ebd840ba9 | ||
|
|
9d356cd664 | ||
|
|
b00f43ee06 | ||
|
|
23167ef974 | ||
|
|
8b003aef16 | ||
|
|
11934eed05 | ||
|
|
d552c59d11 | ||
|
|
23e1085a37 | ||
|
|
a352615e77 | ||
|
|
d210da501a | ||
|
|
6288199a93 | ||
|
|
6d9781fe15 | ||
|
|
5b34ed1a7e | ||
|
|
3d9a469347 | ||
|
|
7640ba7614 | ||
|
|
c790fe3c3b | ||
|
|
4886bdb3f5 | ||
|
|
4aef07ccd3 | ||
|
|
e0c49377d6 | ||
|
|
dc37dd2c34 | ||
|
|
02aad9020a | ||
|
|
d59d1091dc | ||
|
|
60d6bfeec4 | ||
|
|
bf89acb3d2 | ||
|
|
35d6f47bd6 | ||
|
|
d24b71614a | ||
|
|
765b961f4d | ||
|
|
9ab4bd10a5 | ||
|
|
14c5bed4fc | ||
|
|
c1bd6dcf64 | ||
|
|
1c8b83ec2f | ||
|
|
bb6f470df5 | ||
|
|
01af71a5ae | ||
|
|
d4ff8a5f4f | ||
|
|
47e3c2b3c5 | ||
|
|
e8ecff586a | ||
|
|
11a9ad5d49 | ||
|
|
23940e4752 | ||
|
|
8cb0f64477 | ||
|
|
bbaf033618 | ||
|
|
57cca7ef71 | ||
|
|
2d38beb7be | ||
|
|
4372a8c5ba | ||
|
|
422dc0fde3 | ||
|
|
3d59411a1a | ||
|
|
fe66ac18a4 | ||
|
|
bda408d966 | ||
|
|
33631502c9 | ||
|
|
f2a608d146 | ||
|
|
378b9f67a2 | ||
|
|
e841d41640 | ||
|
|
43ba466783 | ||
|
|
5c9f738012 | ||
|
|
56c5fb35b8 | ||
|
|
b19c52f61b | ||
|
|
c0641dbf64 | ||
|
|
3b5cb3b0cb | ||
|
|
fd2ed2174f | ||
|
|
5bbaa304e3 | ||
|
|
db938778af | ||
|
|
1155b9b5c0 | ||
|
|
8fa93ef030 | ||
|
|
f4ad1a15f5 | ||
|
|
d2cbd928e2 | ||
|
|
2346dfba4e | ||
|
|
f6ad2fa01a | ||
|
|
7bc56f3dea | ||
|
|
03001eb4e0 | ||
|
|
91974ce981 | ||
|
|
c6417e3527 | ||
|
|
161687ae4c | ||
|
|
4da5a07d1d | ||
|
|
2cb83d388b | ||
|
|
ba9c0f624e | ||
|
|
ff65f80c98 | ||
|
|
42240f5e9e | ||
|
|
e6b06b561d | ||
|
|
11cbc30a10 | ||
|
|
60a474f30c | ||
|
|
6e8fec5684 | ||
|
|
470aa42d7b | ||
|
|
a74d96bb96 | ||
|
|
9798a60728 | ||
|
|
e028d443d5 | ||
|
|
74859b966b | ||
|
|
76a5559ca2 | ||
|
|
7b5e3e2d1f | ||
|
|
c4a1018213 | ||
|
|
62d72ad84c | ||
|
|
e6fe475aea | ||
|
|
e95ac52ff4 | ||
|
|
61f8140f91 | ||
|
|
09c0cd7849 | ||
|
|
5a368a9ab8 | ||
|
|
fbcd8887ee | ||
|
|
c0838e7e41 | ||
|
|
9c6ab99bf1 | ||
|
|
87ab311764 | ||
|
|
7f4318a10d | ||
|
|
5232f632c8 | ||
|
|
615a775fdf | ||
|
|
b10da77e09 | ||
|
|
cefc5c005c | ||
|
|
15c075e6a6 | ||
|
|
f6d1e685e0 | ||
|
|
81108e150b | ||
|
|
dad9aefefa | ||
|
|
71d49cb12b | ||
|
|
f7e2efaaa2 | ||
|
|
557208e16b | ||
|
|
53401dbb0c | ||
|
|
6c52ac37dd | ||
|
|
95a17a3ffc | ||
|
|
3318741508 | ||
|
|
3d7ab2690c | ||
|
|
17eefcd24b | ||
|
|
6f62ce7997 | ||
|
|
2228a32d1a | ||
|
|
a6f79e951b | ||
|
|
096dfd48f0 | ||
|
|
3922970bfc | ||
|
|
2ab0edd5c6 | ||
|
|
4b3272bcd4 | ||
|
|
80d5d24637 | ||
|
|
e40397e239 | ||
|
|
15bc7fdc3f | ||
|
|
da049be58d | ||
|
|
6ec64e8a03 | ||
|
|
bca56cbba6 | ||
|
|
103b05413e | ||
|
|
bb48aa0cc8 | ||
|
|
6ffb4bcb93 | ||
|
|
169bbe88c0 | ||
|
|
08595594d7 | ||
|
|
30782020ad | ||
|
|
540bbac35b | ||
|
|
c4d5595ca9 | ||
|
|
86d1eb8030 | ||
|
|
ab529e5ee5 | ||
|
|
15cb1f4311 | ||
|
|
97ca00868f | ||
|
|
df8885c350 | ||
|
|
d87cdef00b | ||
|
|
4e27a3a84a | ||
|
|
8685464c53 | ||
|
|
26b5939f9f | ||
|
|
95bcace6bd | ||
|
|
ce57cdf260 | ||
|
|
999e7a7b9d | ||
|
|
94cee4388e | ||
|
|
3ec138ba99 | ||
|
|
dcc7e72ec1 | ||
|
|
f419c0f96d | ||
|
|
17101a8901 | ||
|
|
66aa00fb50 | ||
|
|
9d6e151a6f | ||
|
|
0df41d3eff | ||
|
|
2a1ed8cc7a | ||
|
|
c5951a10ff | ||
|
|
3dbd29ab50 | ||
|
|
1b50c7a5b4 | ||
|
|
cbd70d1b88 | ||
|
|
0ae8949d40 | ||
|
|
0975d970d1 | ||
|
|
7808673431 | ||
|
|
50ed921ca1 | ||
|
|
b34c29ec35 | ||
|
|
97e5ded043 | ||
|
|
7ceeafbae8 | ||
|
|
54b4a24a14 | ||
|
|
e1bfb343f4 | ||
|
|
7da423bf3c | ||
|
|
fa83ee579c | ||
|
|
25c694aa2a | ||
|
|
e064d394ba | ||
|
|
5a001f33b6 |
+3
-1
@@ -25,7 +25,9 @@ category: Added
|
||||
|
||||
发布 beta 时,`scripts/release/prepare-changelog.sh` 会按分类和文件名稳定排序,
|
||||
将未归档 fragments 汇总为唯一的版本章节,并移动到
|
||||
`.changes/released/<version>/`。因此 release-seal PR 是唯一会修改
|
||||
`.changes/released/<version>/`。beta 发布后若有新 fragments 合入并直接准备 stable,
|
||||
stable 封板会把它们追加到明确的 post-beta 小节,并归档到正式版本目录;没有新
|
||||
fragments 时仍只生成原有 beta 晋级模板。因此 release-seal PR 是唯一会修改
|
||||
`CHANGELOG.md` 的 PR;它同时归档已消费的 fragments,供审计追溯。
|
||||
归档只能在同一个 release-seal PR 中以原样移动完成;CI 会拒绝直接修改、
|
||||
删除或重写已归档文件。
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Command typo guidance** — returns a validation error with up to three nearest command suggestions and the parent `--help` entry instead of printing the full command list.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
@@ -0,0 +1,11 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
@@ -0,0 +1,24 @@
|
||||
---
|
||||
category: Changed
|
||||
---
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
@@ -0,0 +1,13 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Fixed
|
||||
---
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
category: Added
|
||||
---
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
@@ -4,3 +4,13 @@ paths:
|
||||
# GitHub Actions added concurrency.queue in 2026. actionlint v1.7.12's
|
||||
# bundled workflow schema has not caught up with the platform syntax.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
.github/workflows/coverage-baseline-promotion.yml:
|
||||
ignore:
|
||||
# Serialize every acknowledgement for one Formula target without
|
||||
# allowing Actions' default single-pending replacement to orphan a run.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
.github/workflows/coverage-baseline-repair.yml:
|
||||
ignore:
|
||||
# Keep the closed-event dispatcher and its exact-SHA producer queued for
|
||||
# the same target instead of replacing either half of the repair chain.
|
||||
- 'unexpected key "queue" for "concurrency" section'
|
||||
|
||||
+465
-9
@@ -5,12 +5,16 @@ on:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review, edited, auto_merge_enabled, auto_merge_disabled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
|
||||
# Only duplicate runs for the exact PR base/head revision cancel each other.
|
||||
# A later revision must not kill an earlier cold-cache producer, and every
|
||||
# protected-main SHA keeps an independent producer run.
|
||||
group: ci-${{ github.workflow }}-${{ github.event_name == 'pull_request' && format('pr-{0}-{1}-{2}', github.event.pull_request.number, github.event.pull_request.base.sha, github.event.pull_request.head.sha) || format('push-{0}', github.sha) }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
@@ -534,7 +538,8 @@ jobs:
|
||||
# where the Schema partition alone owned most of the wall clock. The
|
||||
# app-<partition> names are pinned to the helper's partition set by
|
||||
# TestCIAppRacePartitionMatrixMatchesHelper, so a partition can never lose
|
||||
# its job silently.
|
||||
# its job silently. The CrossPlatformCoverage-heavy C range is split again
|
||||
# to retain headroom on runners reclaimed near the five-minute mark.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -542,7 +547,11 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -678,7 +687,8 @@ jobs:
|
||||
# partitions run concurrently and each releases its framework registries
|
||||
# when the process exits; cli/smoke need headroom beyond go test -timeout for
|
||||
# setup + assembly. The app-<partition> names are pinned to the helper's
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper.
|
||||
# partition set by TestCIAppRacePartitionMatrixMatchesHelper. The
|
||||
# CrossPlatformCoverage-heavy C range is split again for runner headroom.
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
@@ -686,7 +696,11 @@ jobs:
|
||||
shard:
|
||||
- app-schema
|
||||
- app-a-b
|
||||
- app-c
|
||||
- app-c-a-l
|
||||
- app-c-m-o
|
||||
- app-c-p-r
|
||||
- app-c-s-z
|
||||
- app-c-other
|
||||
- app-d-r
|
||||
- app-s-z-example-fuzz
|
||||
- generators
|
||||
@@ -827,7 +841,9 @@ jobs:
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions: {}
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Verify test shards
|
||||
env:
|
||||
@@ -906,6 +922,283 @@ jobs:
|
||||
done
|
||||
test "$failed" -eq 0
|
||||
|
||||
# Null and non-built-in merge identities emit either the protected-main
|
||||
# push or the trusted pull_request_target closed repair. The built-in
|
||||
# Actions identity is the exceptional unsafe path, so its own token must
|
||||
# prove that main-merge-writers never lets it update main.
|
||||
- name: Verify auto-merge identity
|
||||
if: github.event_name == 'pull_request' && github.event.pull_request.draft == false
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
REVIEWER_ROUTER_APP_SLUG: ${{ vars.REVIEWER_ROUTER_APP_SLUG }}
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const pullNumber = context.payload.pull_request.number;
|
||||
const eventHeadSha = context.payload.pull_request.head.sha;
|
||||
const eventBaseSha = context.payload.pull_request.base.sha;
|
||||
const appSlug = process.env.REVIEWER_ROUTER_APP_SLUG?.trim();
|
||||
if (
|
||||
!appSlug ||
|
||||
appSlug !== appSlug.toLowerCase() ||
|
||||
appSlug === 'github-actions'
|
||||
) {
|
||||
core.setFailed(
|
||||
'Reviewer Router App slug repository variable is missing or unsafe.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
const expectedAppOwner = `${appSlug}[bot]`;
|
||||
const writerRulesetName = 'main-merge-writers';
|
||||
const skipWorkflowPattern =
|
||||
/\[(?:skip ci|ci skip|no ci|skip actions|actions skip)\]|\bskip-checks\s*:\s*true\b/i;
|
||||
const {data: repository} = await github.rest.repos.get({owner, repo});
|
||||
function classifyMergeDefaults(repository) {
|
||||
if (
|
||||
repository === null ||
|
||||
typeof repository !== 'object' ||
|
||||
Array.isArray(repository)
|
||||
) {
|
||||
return 'invalid';
|
||||
}
|
||||
const hasTitle = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_title',
|
||||
);
|
||||
const hasMessage = Object.prototype.hasOwnProperty.call(
|
||||
repository,
|
||||
'merge_commit_message',
|
||||
);
|
||||
if (!hasTitle && !hasMessage) {
|
||||
return 'omitted';
|
||||
}
|
||||
if (!hasTitle || !hasMessage) {
|
||||
return 'invalid';
|
||||
}
|
||||
if (
|
||||
repository.merge_commit_title === 'MERGE_MESSAGE' &&
|
||||
['PR_TITLE', 'BLANK'].includes(repository.merge_commit_message)
|
||||
) {
|
||||
return 'reviewed';
|
||||
}
|
||||
return 'invalid';
|
||||
}
|
||||
const mergeDefaultsProjection = classifyMergeDefaults(repository);
|
||||
if (mergeDefaultsProjection === 'invalid') {
|
||||
core.setFailed(
|
||||
'Repository merge-message defaults are malformed or changed from their reviewed values.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (mergeDefaultsProjection === 'omitted') {
|
||||
core.info(
|
||||
'Read-only CI cannot observe repository merge-message defaults; exact validation is delegated to the dedicated App.',
|
||||
);
|
||||
}
|
||||
|
||||
const appliedRules = await github.paginate(
|
||||
'GET /repos/{owner}/{repo}/rules/branches/{branch}',
|
||||
{owner, repo, branch: 'main', per_page: 100},
|
||||
);
|
||||
const repositorySource = `${owner}/${repo}`.toLowerCase();
|
||||
const applicableRulesetIDs = [
|
||||
...new Set(
|
||||
appliedRules
|
||||
.filter(rule =>
|
||||
rule.ruleset_source_type === 'Repository' &&
|
||||
rule.ruleset_source?.toLowerCase() === repositorySource &&
|
||||
Number.isSafeInteger(Number(rule.ruleset_id)) &&
|
||||
Number(rule.ruleset_id) > 0,
|
||||
)
|
||||
.map(rule => Number(rule.ruleset_id)),
|
||||
),
|
||||
];
|
||||
const activeMainRulesets = [];
|
||||
for (const rulesetID of applicableRulesetIDs) {
|
||||
const {data: ruleset} = await github.request(
|
||||
'GET /repos/{owner}/{repo}/rulesets/{ruleset_id}',
|
||||
{owner, repo, ruleset_id: rulesetID},
|
||||
);
|
||||
if (
|
||||
ruleset.enforcement !== 'active' ||
|
||||
ruleset.target !== 'branch' ||
|
||||
ruleset.source_type !== 'Repository' ||
|
||||
ruleset.source?.toLowerCase() !== repositorySource
|
||||
) {
|
||||
core.setFailed(
|
||||
`Applicable repository ruleset ${ruleset.name || rulesetID} is not an active branch ruleset owned by this repository.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
activeMainRulesets.push(ruleset);
|
||||
}
|
||||
|
||||
const writerRulesets = activeMainRulesets.filter(
|
||||
ruleset => ruleset.name === writerRulesetName,
|
||||
);
|
||||
if (writerRulesets.length !== 1) {
|
||||
core.setFailed(
|
||||
`Expected exactly one active ${writerRulesetName} ruleset on main; found ${writerRulesets.length}.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const writerRuleset = writerRulesets[0];
|
||||
const writerIncludes = writerRuleset.conditions?.ref_name?.include || [];
|
||||
const writerExcludes = writerRuleset.conditions?.ref_name?.exclude || [];
|
||||
// GitHub's read projection omits the entire parameters property
|
||||
// when this exception is disabled. Accept only that exact omission
|
||||
// or a one-field object containing exact false.
|
||||
function isStrictUpdateRule(rule) {
|
||||
if (rule?.type !== 'update') {
|
||||
return false;
|
||||
}
|
||||
if (!Object.prototype.hasOwnProperty.call(rule, 'parameters')) {
|
||||
return true;
|
||||
}
|
||||
const parameters = rule.parameters;
|
||||
if (
|
||||
parameters === null ||
|
||||
typeof parameters !== 'object' ||
|
||||
Array.isArray(parameters)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const parameterKeys = Object.keys(parameters);
|
||||
return (
|
||||
parameterKeys.length === 1 &&
|
||||
parameterKeys[0] === 'update_allows_fetch_and_merge' &&
|
||||
parameters.update_allows_fetch_and_merge === false
|
||||
);
|
||||
}
|
||||
function isStrictGraphQLUpdateRule(restRuleset, graphRuleset) {
|
||||
const restRulesetID = Number(restRuleset?.id);
|
||||
const graphRulesetID = Number(graphRuleset?.databaseId);
|
||||
const graphRules = graphRuleset?.rules;
|
||||
const graphRule = graphRules?.nodes?.[0];
|
||||
return (
|
||||
Number.isSafeInteger(restRulesetID) &&
|
||||
restRulesetID > 0 &&
|
||||
graphRulesetID === restRulesetID &&
|
||||
graphRuleset.name === restRuleset.name &&
|
||||
graphRuleset.enforcement === 'ACTIVE' &&
|
||||
graphRuleset.target === 'BRANCH' &&
|
||||
graphRules?.totalCount === 1 &&
|
||||
graphRules.nodes?.length === 1 &&
|
||||
graphRule?.type === 'UPDATE' &&
|
||||
graphRule.parameters?.__typename === 'UpdateParameters' &&
|
||||
graphRule.parameters.updateAllowsFetchAndMerge === false
|
||||
);
|
||||
}
|
||||
if (
|
||||
writerIncludes.length !== 1 ||
|
||||
writerIncludes[0] !== 'refs/heads/main' ||
|
||||
writerExcludes.length !== 0 ||
|
||||
typeof writerRuleset.node_id !== 'string' ||
|
||||
!writerRuleset.node_id ||
|
||||
writerRuleset.rules?.length !== 1 ||
|
||||
!isStrictUpdateRule(writerRuleset.rules[0]) ||
|
||||
writerRuleset.current_user_can_bypass !== 'never'
|
||||
) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must target only refs/heads/main, contain only the strict update rule, and deny this built-in Actions identity any bypass.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const {node: graphWriterRuleset} = await github.graphql(
|
||||
`query ReviewerRouterWriterRule($rulesetID: ID!) {
|
||||
node(id: $rulesetID) {
|
||||
... on RepositoryRuleset {
|
||||
databaseId
|
||||
name
|
||||
enforcement
|
||||
target
|
||||
rules(first: 2) {
|
||||
totalCount
|
||||
nodes {
|
||||
type
|
||||
parameters {
|
||||
__typename
|
||||
... on UpdateParameters {
|
||||
updateAllowsFetchAndMerge
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{rulesetID: writerRuleset.node_id},
|
||||
);
|
||||
if (!isStrictGraphQLUpdateRule(writerRuleset, graphWriterRuleset)) {
|
||||
core.setFailed(
|
||||
`${writerRulesetName} must expose one strict UPDATE rule with updateAllowsFetchAndMerge=false through GraphQL.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const maxAttempts = 6;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.base.sha !== eventBaseSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} state or revision changed before the Test aggregate verified auto-merge identity.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const mergeTexts = [
|
||||
currentPull.title,
|
||||
currentPull.auto_merge?.commit_title,
|
||||
currentPull.auto_merge?.commit_message,
|
||||
].filter(value => typeof value === 'string');
|
||||
if (mergeTexts.some(value => skipWorkflowPattern.test(value))) {
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} merge metadata contains a GitHub workflow-skip directive.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!currentPull.auto_merge) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has no auto-merge request; protected-main push or closed-event repair remains authoritative.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const enabledBy = currentPull.auto_merge.enabled_by?.login?.toLowerCase();
|
||||
const safeCommitHeadline = `Merge pull request #${pullNumber}`;
|
||||
const safeCommitBody =
|
||||
`Merged by the dedicated Reviewer Router GitHub App for PR #${pullNumber}.`;
|
||||
if (
|
||||
enabledBy === expectedAppOwner &&
|
||||
currentPull.auto_merge.commit_title === safeCommitHeadline &&
|
||||
currentPull.auto_merge.commit_message === safeCommitBody
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge is owned by the reviewed ${expectedAppOwner} identity with fixed metadata.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
core.info(
|
||||
`PR #${pullNumber} auto-merge owner or metadata is not the reviewed App value; waiting for Reviewer Router takeover (${attempt}/${maxAttempts}).`,
|
||||
);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
core.setFailed(
|
||||
`PR #${pullNumber} auto-merge must be null or owned by ${expectedAppOwner} with the reviewed fixed metadata.`,
|
||||
);
|
||||
}
|
||||
|
||||
test-darwin:
|
||||
name: Test (macOS auth/keychain)
|
||||
needs: lint
|
||||
@@ -1236,7 +1529,7 @@ jobs:
|
||||
needs: lint
|
||||
if: ${{ needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
@@ -1371,6 +1664,143 @@ jobs:
|
||||
path: coverage-base.txt
|
||||
retention-days: 1
|
||||
|
||||
# Documentation and release-seal pushes do not change executable coverage,
|
||||
# but their new main SHA is still a future PR merge base. Promote only an
|
||||
# exact predecessor cache after independently proving the whole push changed
|
||||
# metadata paths; fall back to a full authoritative profile on a cold chain.
|
||||
coverage-main-metadata:
|
||||
name: Coverage (main metadata cache)
|
||||
needs: lint
|
||||
if: ${{ github.event_name == 'push' && (needs.lint.outputs.changelog_only == 'true' || needs.lint.outputs.docs_only == 'true') }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out exact metadata-only main revision
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go-metadata
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Verify metadata-only main successor
|
||||
shell: bash
|
||||
env:
|
||||
PUSH_BEFORE_SHA: ${{ github.event.before }}
|
||||
PUSH_AFTER_SHA: ${{ github.event.after }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
full_commit='^[0-9a-f]{40}$'
|
||||
[[ "$PUSH_BEFORE_SHA" =~ $full_commit ]]
|
||||
[[ "$PUSH_AFTER_SHA" =~ $full_commit ]]
|
||||
test "$PUSH_BEFORE_SHA" != 0000000000000000000000000000000000000000
|
||||
test "$PUSH_AFTER_SHA" = "$GITHUB_SHA"
|
||||
test "$(git rev-parse HEAD)" = "$GITHUB_SHA"
|
||||
git rev-parse --verify "${PUSH_BEFORE_SHA}^{commit}" >/dev/null
|
||||
git merge-base --is-ancestor "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA"
|
||||
|
||||
changed_count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
changed_count=$((changed_count + 1))
|
||||
case "$path" in
|
||||
CHANGELOG.md|README.md|README_zh.md|CONTRIBUTING.md|SECURITY.md|CODE_OF_CONDUCT.md|LICENSE|NOTICE|.github/PULL_REQUEST_TEMPLATE.md|.github/ISSUE_TEMPLATE/*|docs/*)
|
||||
;;
|
||||
.changes/*)
|
||||
if [[ "$path" =~ ^\.changes/[a-z0-9][a-z0-9._-]*\.md$ ]] ||
|
||||
[[ "$path" =~ ^\.changes/released/[0-9]+\.[0-9]+\.[0-9]+(-beta\.[1-9][0-9]*)?/[a-z0-9][a-z0-9._-]*\.md$ ]]; then
|
||||
continue
|
||||
fi
|
||||
echo "Refusing coverage-cache promotion for unreviewed change-fragment path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Refusing coverage-cache promotion for executable path: $path" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
done < <(git diff --name-only --no-renames -z "$PUSH_BEFORE_SHA" "$PUSH_AFTER_SHA")
|
||||
test "$changed_count" -gt 0
|
||||
echo "COVERAGE_SOURCE_REF=$PUSH_BEFORE_SHA" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Restore existing current-SHA coverage profile
|
||||
id: metadata-current-cache
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate existing current-SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact predecessor coverage profile
|
||||
id: metadata-source-cache
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ env.COVERAGE_SOURCE_REF }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted predecessor coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold metadata baseline
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true' && steps.metadata-source-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save metadata main SHA coverage profile
|
||||
if: steps.metadata-current-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
|
||||
# actions/cache/save reports upload failures as warnings. Convert an
|
||||
# absent exact target key into a hard producer failure.
|
||||
- name: Verify metadata main SHA coverage cache exists
|
||||
id: metadata-target-cache-verification
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go-metadata.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact metadata main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.metadata-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
coverage:
|
||||
name: Coverage
|
||||
needs:
|
||||
@@ -1379,6 +1809,7 @@ jobs:
|
||||
- coverage-current-full
|
||||
- coverage-supporting
|
||||
- coverage-baseline
|
||||
- coverage-main-metadata
|
||||
- coverage-darwin
|
||||
- coverage-windows
|
||||
if: ${{ always() && needs.lint.result == 'success' }}
|
||||
@@ -1395,6 +1826,7 @@ jobs:
|
||||
CURRENT_FULL_RESULT: ${{ needs.coverage-current-full.result }}
|
||||
SUPPORTING_RESULT: ${{ needs.coverage-supporting.result }}
|
||||
BASELINE_RESULT: ${{ needs.coverage-baseline.result }}
|
||||
MAIN_METADATA_RESULT: ${{ needs.coverage-main-metadata.result }}
|
||||
DARWIN_RESULT: ${{ needs.coverage-darwin.result }}
|
||||
WINDOWS_RESULT: ${{ needs.coverage-windows.result }}
|
||||
run: |
|
||||
@@ -1403,10 +1835,14 @@ jobs:
|
||||
current_full_expected=skipped
|
||||
supporting_expected=skipped
|
||||
baseline_expected=success
|
||||
main_metadata_expected=skipped
|
||||
native_expected=skipped
|
||||
if [ "$CHANGELOG_ONLY" = true ] || [ "$DOCS_ONLY" = true ]; then
|
||||
current_expected=skipped
|
||||
baseline_expected=skipped
|
||||
if [ "$GITHUB_EVENT_NAME" = push ]; then
|
||||
main_metadata_expected=success
|
||||
fi
|
||||
elif [ "$FULL_SUITE" = true ]; then
|
||||
current_expected=skipped
|
||||
current_full_expected=success
|
||||
@@ -1422,7 +1858,8 @@ jobs:
|
||||
"current:$CURRENT_RESULT:$current_expected" \
|
||||
"current shards:$CURRENT_FULL_RESULT:$current_full_expected" \
|
||||
"supporting:$SUPPORTING_RESULT:$supporting_expected" \
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected"
|
||||
"baseline:$BASELINE_RESULT:$baseline_expected" \
|
||||
"main metadata cache:$MAIN_METADATA_RESULT:$main_metadata_expected"
|
||||
do
|
||||
name="${profile%%:*}"
|
||||
remainder="${profile#*:}"
|
||||
@@ -1562,6 +1999,22 @@ jobs:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify push coverage cache exists
|
||||
id: push-cache-verification
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ github.sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact push coverage cache
|
||||
if: github.event_name == 'push' && needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true' && needs.lint.outputs.full_suite == 'true'
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.push-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Generate coverage report
|
||||
if: needs.lint.outputs.changelog_only != 'true' && needs.lint.outputs.docs_only != 'true'
|
||||
run: |
|
||||
@@ -1589,7 +2042,10 @@ jobs:
|
||||
name: Policy
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
# Full policy regenerates and validates the runtime Schema several times.
|
||||
# Keep job-level headroom for large reviewed command-surface additions;
|
||||
# individual policy gates retain their own fail-closed checks.
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
name: Coverage Baseline Promotion
|
||||
|
||||
run-name: Promote coverage baseline for ${{ github.event.client_payload.target_sha }}
|
||||
|
||||
on:
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-promote]
|
||||
|
||||
# repository_dispatch loads this workflow from the protected default branch.
|
||||
# The requested target is treated as untrusted input until the validation step
|
||||
# proves it is an exact Formula-only successor already contained in main.
|
||||
permissions:
|
||||
checks: write
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-promotion-${{ github.event.client_payload.target_sha }}
|
||||
cancel-in-progress: false
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
promote:
|
||||
if: github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Validate Formula-only main target
|
||||
id: validate-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const targetSha = context.payload.client_payload?.target_sha;
|
||||
const sourceRunId = context.payload.client_payload?.source_run_id;
|
||||
const checkRunId = Number(context.payload.client_payload?.check_run_id);
|
||||
if (!/^[0-9a-f]{40}$/.test(targetSha || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one full target_sha');
|
||||
}
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunId || '')) {
|
||||
throw new Error('coverage-baseline-promote requires one source_run_id');
|
||||
}
|
||||
if (!Number.isSafeInteger(checkRunId) || checkRunId <= 0) {
|
||||
throw new Error('coverage-baseline-promote requires one safe check_run_id');
|
||||
}
|
||||
|
||||
// Bind the finalizer before any target or cache validation. A
|
||||
// later failure must complete the release-created acknowledgement
|
||||
// instead of leaving Release to poll a permanently queued check.
|
||||
const promotionExternalId = `release-${sourceRunId}-${targetSha}`;
|
||||
const {data: promotionCheck} = await github.rest.checks.get({
|
||||
owner,
|
||||
repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
promotionCheck.id !== checkRunId ||
|
||||
promotionCheck.head_sha !== targetSha ||
|
||||
promotionCheck.name !== 'Coverage Baseline Cache' ||
|
||||
promotionCheck.external_id !== promotionExternalId ||
|
||||
promotionCheck.app?.slug !== 'github-actions' ||
|
||||
promotionCheck.status !== 'queued' ||
|
||||
promotionCheck.conclusion !== null
|
||||
) {
|
||||
throw new Error('coverage baseline acknowledgement has an invalid identity');
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.setOutput('check_run_id', String(checkRunId));
|
||||
core.setOutput('check_external_id', promotionExternalId);
|
||||
|
||||
const {data: targetCommit} = await github.rest.repos.getCommit({
|
||||
owner,
|
||||
repo,
|
||||
ref: targetSha,
|
||||
per_page: 100,
|
||||
});
|
||||
const files = targetCommit.files || [];
|
||||
const message = targetCommit.commit.message;
|
||||
const formulaPath = files[0]?.filename;
|
||||
const stableFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli.rb' &&
|
||||
/^chore: update formula for v[0-9]+\.[0-9]+\.[0-9]+ \[skip ci\]$/.test(message);
|
||||
const betaFormula =
|
||||
formulaPath === 'Formula/dingtalk-workspace-cli-beta.rb' &&
|
||||
/^chore: update beta formula for v[0-9]+\.[0-9]+\.[0-9]+-beta\.[1-9][0-9]* \[skip ci\]$/.test(message);
|
||||
if (
|
||||
targetCommit.sha !== targetSha ||
|
||||
targetCommit.parents.length !== 1 ||
|
||||
targetCommit.author?.login !== 'github-actions[bot]' ||
|
||||
targetCommit.committer?.login !== 'github-actions[bot]' ||
|
||||
files.length !== 1 ||
|
||||
!['added', 'modified'].includes(files[0].status) ||
|
||||
(!stableFormula && !betaFormula)
|
||||
) {
|
||||
throw new Error(
|
||||
`${targetSha} is not an exact release-produced Formula-only commit`,
|
||||
);
|
||||
}
|
||||
|
||||
const parentSha = targetCommit.parents[0].sha;
|
||||
const requiredContexts = [
|
||||
'Lint',
|
||||
'Test',
|
||||
'Coverage',
|
||||
'Policy',
|
||||
'Edition',
|
||||
'Interface Integrity',
|
||||
'AI Behavior',
|
||||
'CLI Smoke',
|
||||
'Mock MCP',
|
||||
];
|
||||
async function requireSuccessfulAdmission(ref, label) {
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
const runs = await github.paginate(github.rest.checks.listForRef, {
|
||||
owner,
|
||||
repo,
|
||||
ref,
|
||||
filter: 'latest',
|
||||
per_page: 100,
|
||||
});
|
||||
const latestByName = new Map();
|
||||
for (const run of runs) {
|
||||
if (
|
||||
run.head_sha !== ref ||
|
||||
run.app?.slug !== 'github-actions' ||
|
||||
!requiredContexts.includes(run.name)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const current = latestByName.get(run.name);
|
||||
if (!current || run.id > current.id) {
|
||||
latestByName.set(run.name, run);
|
||||
}
|
||||
}
|
||||
const invalid = requiredContexts.filter((name) => {
|
||||
const run = latestByName.get(name);
|
||||
return !run || run.conclusion !== 'success';
|
||||
});
|
||||
if (invalid.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
continue;
|
||||
}
|
||||
throw new Error(
|
||||
`${label} ${ref} lacks successful Code Admission contexts: ${invalid.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
await requireSuccessfulAdmission(parentSha, 'Formula parent');
|
||||
await requireSuccessfulAdmission(targetSha, 'Formula target');
|
||||
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: context.payload.repository.default_branch,
|
||||
});
|
||||
const {data: containment} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
if (!['ahead', 'identical'].includes(containment.status)) {
|
||||
throw new Error(`${targetSha} is not contained in the protected default branch`);
|
||||
}
|
||||
|
||||
core.setOutput('parent_sha', parentSha);
|
||||
core.setOutput('formula_path', formulaPath);
|
||||
|
||||
- name: Mark Formula cache promotion in progress
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: Number('${{ steps.validate-target.outputs.check_run_id }}'),
|
||||
status: 'in_progress',
|
||||
started_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Producing exact-SHA coverage baseline',
|
||||
summary: 'The trusted default-branch workflow is validating or producing the main-scoped cache.',
|
||||
},
|
||||
});
|
||||
|
||||
- name: Check out validated Formula-only target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.validate-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out Formula-only identity
|
||||
shell: bash
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.validate-target.outputs.target_sha }}
|
||||
PARENT_SHA: ${{ steps.validate-target.outputs.parent_sha }}
|
||||
FORMULA_PATH: ${{ steps.validate-target.outputs.formula_path }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
test "$(git rev-parse HEAD^)" = "$PARENT_SHA"
|
||||
test "$(git diff --name-only --no-renames "$PARENT_SHA" "$TARGET_SHA")" = "$FORMULA_PATH"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore existing target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Restore exact Formula parent coverage profile
|
||||
id: parent-cache
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.parent_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate promoted Formula parent profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute cold Formula baseline
|
||||
if: steps.target-cache.outputs.cache-hit != 'true' && steps.parent-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save Formula main SHA coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Verify Formula main SHA coverage cache exists
|
||||
id: formula-target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.validate-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact Formula main SHA coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.formula-target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
|
||||
- name: Complete Formula cache promotion acknowledgement
|
||||
if: ${{ always() && steps.validate-target.outputs.check_run_id != '' }}
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
PROMOTION_JOB_STATUS: ${{ job.status }}
|
||||
with:
|
||||
script: |
|
||||
const checkRunId = Number('${{ steps.validate-target.outputs.check_run_id }}');
|
||||
const targetSha = '${{ steps.validate-target.outputs.target_sha }}';
|
||||
const expectedExternalId = '${{ steps.validate-target.outputs.check_external_id }}';
|
||||
const {data: currentCheck} = await github.rest.checks.get({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
currentCheck.head_sha !== targetSha ||
|
||||
currentCheck.name !== 'Coverage Baseline Cache' ||
|
||||
currentCheck.external_id !== expectedExternalId ||
|
||||
currentCheck.app?.slug !== 'github-actions'
|
||||
) {
|
||||
throw new Error('refusing to update a changed promotion acknowledgement');
|
||||
}
|
||||
const succeeded = process.env.PROMOTION_JOB_STATUS === 'success';
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
status: 'completed',
|
||||
conclusion: succeeded ? 'success' : 'failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: succeeded
|
||||
? 'Exact-SHA coverage baseline is available'
|
||||
: 'Exact-SHA coverage baseline promotion failed',
|
||||
summary: succeeded
|
||||
? `Verified the main-scoped exact cache for ${targetSha}.`
|
||||
: `Promotion failed for ${targetSha}; rerun the failed Release job after correcting the producer.`,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,560 @@
|
||||
name: Coverage Baseline Repair
|
||||
|
||||
run-name: Repair coverage baseline from ${{ github.event_name }}
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [closed]
|
||||
workflow_run:
|
||||
workflows: [CI]
|
||||
types: [completed]
|
||||
branches: [main]
|
||||
repository_dispatch:
|
||||
types: [coverage-baseline-repair]
|
||||
schedule:
|
||||
- cron: "23 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# pull_request_target and workflow_run are allowed to inspect only GitHub API
|
||||
# data and dispatch the trusted producer. GitHub deliberately makes both
|
||||
# triggers read-only for the default-branch cache, so all checkout and cache
|
||||
# writes live in repository_dispatch, schedule, or main-only workflow_dispatch.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: coverage-baseline-repair-${{ github.event_name == 'pull_request_target' && github.event.pull_request.merge_commit_sha || github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event_name == 'repository_dispatch' && github.event.client_payload.merge_commit_sha || github.sha }}
|
||||
cancel-in-progress: false
|
||||
# Retain every pending repair for one target. actionlint v1.7.12's bundled
|
||||
# schema predates GitHub's concurrency.queue support.
|
||||
queue: max
|
||||
|
||||
jobs:
|
||||
dispatch-merged-pr:
|
||||
if: ${{ github.event_name == 'pull_request_target' && github.event.pull_request.merged == true && github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
# Never check out or execute pull-request content in this privileged
|
||||
# base-owned event. Re-read the merged PR, bind every immutable identity,
|
||||
# prove the result is in main, and send only those values to the producer.
|
||||
- name: Dispatch trusted merged-PR repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const eventPull = context.payload.pull_request;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const pullNumber = Number(eventPull?.number);
|
||||
const headSha = eventPull?.head?.sha;
|
||||
const baseRef = eventPull?.base?.ref;
|
||||
const mergeCommitSha = eventPull?.merge_commit_sha;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
pullNumber <= 0 ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
baseRef !== 'main' ||
|
||||
!fullCommit.test(mergeCommitSha || '')
|
||||
) {
|
||||
throw new Error('closed PR event has an invalid repository or revision identity');
|
||||
}
|
||||
|
||||
// REST base.sha follows the live base branch and can move after
|
||||
// merge. Bind the closed event's stable PR head snapshot and merge
|
||||
// facts, then authorize the target through main containment.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
)) {
|
||||
throw new Error(`PR #${pullNumber} no longer matches the merged-main event`);
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
await requireMainContainment(mergeCommitSha);
|
||||
|
||||
// Normal App or human merges emit a protected-main push run whose
|
||||
// CI producer owns this exact key. Give Actions event delivery a
|
||||
// short visibility window and avoid a duplicate full-suite repair.
|
||||
// A workflow-skip directive or suppressed built-in-token event has
|
||||
// no such run, so only that missing-event path reaches dispatch.
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
ciWorkflow.state !== 'active'
|
||||
) {
|
||||
throw new Error('protected CI workflow identity is not active or exact');
|
||||
}
|
||||
for (let attempt = 1; attempt <= 12; attempt += 1) {
|
||||
const {data: workflowRuns} =
|
||||
await github.rest.actions.listWorkflowRunsForRepo({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
event: 'push',
|
||||
per_page: 100,
|
||||
});
|
||||
const exactPushRun = workflowRuns.workflow_runs.find(run =>
|
||||
run.name === 'CI' &&
|
||||
run.workflow_id === ciWorkflow.id &&
|
||||
run.path === ciWorkflow.path &&
|
||||
run.event === 'push' &&
|
||||
run.head_sha === mergeCommitSha &&
|
||||
run.head_branch === 'main' &&
|
||||
['queued', 'in_progress', 'completed'].includes(run.status),
|
||||
);
|
||||
if (exactPushRun) {
|
||||
core.info(
|
||||
`CI push run ${exactPushRun.id} already owns the exact-SHA producer for ${mergeCommitSha}; repair dispatch is unnecessary.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (attempt < 12) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
|
||||
// repository_dispatch is one of GitHub's explicit GITHUB_TOKEN
|
||||
// recursion exceptions and receives default-branch cache-write scope.
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'merged_pr',
|
||||
pull_number: String(pullNumber),
|
||||
head_sha: headSha,
|
||||
merge_commit_sha: mergeCommitSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for merged PR #${pullNumber} at ${mergeCommitSha}.`,
|
||||
);
|
||||
|
||||
dispatch-failed-ci:
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.repository == 'DingTalk-Real-AI/dingtalk-workspace-cli' &&
|
||||
github.event.workflow_run.name == 'CI' &&
|
||||
github.event.workflow_run.event == 'push' &&
|
||||
github.event.workflow_run.head_branch == 'main' &&
|
||||
github.event.workflow_run.status == 'completed' &&
|
||||
github.event.workflow_run.conclusion != 'success'
|
||||
}}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
actions: read
|
||||
contents: write
|
||||
steps:
|
||||
# workflow_run cannot write the default-branch cache. Re-read the exact
|
||||
# completed CI run from Actions, bind it to the protected CI workflow and
|
||||
# main revision, then use the repository_dispatch recursion exception.
|
||||
- name: Dispatch trusted failed-CI repair
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const upstream = 'DingTalk-Real-AI/dingtalk-workspace-cli';
|
||||
const eventRun = context.payload.workflow_run;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
const runID = Number(eventRun?.id);
|
||||
const runAttempt = Number(eventRun?.run_attempt);
|
||||
const headSha = eventRun?.head_sha;
|
||||
const conclusion = eventRun?.conclusion;
|
||||
if (
|
||||
context.payload.repository?.full_name !== upstream ||
|
||||
context.payload.repository?.default_branch !== 'main' ||
|
||||
!Number.isSafeInteger(runID) ||
|
||||
runID <= 0 ||
|
||||
!Number.isSafeInteger(runAttempt) ||
|
||||
runAttempt <= 0 ||
|
||||
eventRun?.name !== 'CI' ||
|
||||
eventRun?.event !== 'push' ||
|
||||
eventRun?.head_branch !== 'main' ||
|
||||
eventRun?.status !== 'completed' ||
|
||||
typeof conclusion !== 'string' ||
|
||||
conclusion.length === 0 ||
|
||||
conclusion === 'success' ||
|
||||
!fullCommit.test(headSha || '')
|
||||
) {
|
||||
throw new Error('workflow_run event is not one completed non-success main CI push');
|
||||
}
|
||||
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: runID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
eventRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.id !== runID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== headSha ||
|
||||
currentRun.run_attempt !== runAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== conclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !== upstream ||
|
||||
currentRun.head_repository?.full_name !== upstream
|
||||
) {
|
||||
throw new Error(`CI workflow run ${runID} no longer matches the completed event`);
|
||||
}
|
||||
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
owner,
|
||||
repo,
|
||||
event_type: 'coverage-baseline-repair',
|
||||
client_payload: {
|
||||
source: 'failed_ci',
|
||||
workflow_run_id: String(runID),
|
||||
workflow_run_attempt: String(runAttempt),
|
||||
workflow_conclusion: conclusion,
|
||||
merge_commit_sha: headSha,
|
||||
source_run_id: String(context.runId),
|
||||
},
|
||||
});
|
||||
core.info(
|
||||
`Dispatched exact-SHA coverage repair for ${conclusion} CI run ${runID} at ${headSha}.`,
|
||||
);
|
||||
|
||||
repair:
|
||||
if: ${{ github.event_name == 'repository_dispatch' || github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Resolve trusted main repair target
|
||||
id: resolve-target
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
const fullCommit = /^[0-9a-f]{40}$/;
|
||||
if (
|
||||
context.payload.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
context.payload.repository?.default_branch !== 'main'
|
||||
) {
|
||||
throw new Error('coverage repair is restricted to the protected upstream repository');
|
||||
}
|
||||
|
||||
async function requireMainContainment(targetSha) {
|
||||
let lastState = 'not checked';
|
||||
for (let attempt = 1; attempt <= 6; attempt += 1) {
|
||||
try {
|
||||
const {data: branch} = await github.rest.repos.getBranch({
|
||||
owner,
|
||||
repo,
|
||||
branch: 'main',
|
||||
});
|
||||
const {data: comparison} =
|
||||
await github.rest.repos.compareCommitsWithBasehead({
|
||||
owner,
|
||||
repo,
|
||||
basehead: `${targetSha}...${branch.commit.sha}`,
|
||||
});
|
||||
lastState = comparison.status;
|
||||
if (['ahead', 'identical'].includes(comparison.status)) {
|
||||
return branch.commit.sha;
|
||||
}
|
||||
} catch (error) {
|
||||
lastState = error.message;
|
||||
}
|
||||
if (attempt < 6) {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`${targetSha} is not contained in protected main after retries: ${lastState}`,
|
||||
);
|
||||
}
|
||||
|
||||
// The dispatcher froze the stable PR head snapshot in this payload.
|
||||
// Do not re-read mutable base.sha; bind the head and stable merge
|
||||
// facts, then prove protected-main containment below.
|
||||
function isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
mergeCommitSha,
|
||||
) {
|
||||
return (
|
||||
currentPull?.number === pullNumber &&
|
||||
currentPull.state === 'closed' &&
|
||||
currentPull.merged === true &&
|
||||
typeof currentPull.merged_at === 'string' &&
|
||||
currentPull.merged_at.length > 0 &&
|
||||
currentPull.base?.ref === 'main' &&
|
||||
currentPull.head?.sha === headSha &&
|
||||
currentPull.merge_commit_sha === mergeCommitSha
|
||||
);
|
||||
}
|
||||
|
||||
let targetSha;
|
||||
if (context.eventName === 'repository_dispatch') {
|
||||
const payload = context.payload.client_payload || {};
|
||||
const sourceRunIDText = String(payload.source_run_id || '');
|
||||
if (!/^[1-9][0-9]*$/.test(sourceRunIDText)) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid source run');
|
||||
}
|
||||
if (payload.source === 'merged_pr') {
|
||||
const rawPullNumber = String(payload.pull_number || '');
|
||||
const pullNumber = Number(rawPullNumber);
|
||||
const headSha = payload.head_sha;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawPullNumber) ||
|
||||
!Number.isSafeInteger(pullNumber) ||
|
||||
!fullCommit.test(headSha || '') ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid PR identity');
|
||||
}
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (!isStableMergedPRIdentity(
|
||||
currentPull,
|
||||
pullNumber,
|
||||
headSha,
|
||||
targetSha,
|
||||
)) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches merged PR #${pullNumber}`,
|
||||
);
|
||||
}
|
||||
} else if (payload.source === 'failed_ci') {
|
||||
const rawWorkflowRunID = String(payload.workflow_run_id || '');
|
||||
const workflowRunID = Number(rawWorkflowRunID);
|
||||
const rawWorkflowRunAttempt = String(payload.workflow_run_attempt || '');
|
||||
const workflowRunAttempt = Number(rawWorkflowRunAttempt);
|
||||
const workflowConclusion = payload.workflow_conclusion;
|
||||
targetSha = payload.merge_commit_sha;
|
||||
if (
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunID) ||
|
||||
!Number.isSafeInteger(workflowRunID) ||
|
||||
!/^[1-9][0-9]*$/.test(rawWorkflowRunAttempt) ||
|
||||
!Number.isSafeInteger(workflowRunAttempt) ||
|
||||
typeof workflowConclusion !== 'string' ||
|
||||
workflowConclusion.length === 0 ||
|
||||
workflowConclusion === 'success' ||
|
||||
!fullCommit.test(targetSha || '')
|
||||
) {
|
||||
throw new Error('coverage-baseline-repair payload has an invalid CI identity');
|
||||
}
|
||||
const {data: ciWorkflow} = await github.rest.actions.getWorkflow({
|
||||
owner,
|
||||
repo,
|
||||
workflow_id: '.github/workflows/ci.yml',
|
||||
});
|
||||
const {data: currentRun} = await github.rest.actions.getWorkflowRun({
|
||||
owner,
|
||||
repo,
|
||||
run_id: workflowRunID,
|
||||
});
|
||||
if (
|
||||
ciWorkflow.name !== 'CI' ||
|
||||
ciWorkflow.path !== '.github/workflows/ci.yml' ||
|
||||
currentRun.id !== workflowRunID ||
|
||||
currentRun.workflow_id !== ciWorkflow.id ||
|
||||
currentRun.name !== 'CI' ||
|
||||
currentRun.event !== 'push' ||
|
||||
currentRun.head_branch !== 'main' ||
|
||||
currentRun.head_sha !== targetSha ||
|
||||
currentRun.run_attempt !== workflowRunAttempt ||
|
||||
currentRun.status !== 'completed' ||
|
||||
currentRun.conclusion !== workflowConclusion ||
|
||||
currentRun.conclusion === 'success' ||
|
||||
currentRun.repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli' ||
|
||||
currentRun.head_repository?.full_name !==
|
||||
'DingTalk-Real-AI/dingtalk-workspace-cli'
|
||||
) {
|
||||
throw new Error(
|
||||
`repair payload no longer matches failed CI run ${workflowRunID}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
throw new Error('coverage-baseline-repair payload has an unknown source');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
} else {
|
||||
if (context.ref !== 'refs/heads/main') {
|
||||
throw new Error('scheduled and manual repair must run from refs/heads/main');
|
||||
}
|
||||
// github.sha is the default-branch tip that keyed this workflow's
|
||||
// concurrency group. Keep the producer bound to that exact
|
||||
// event-time target even if main advances while this run queues.
|
||||
targetSha = context.sha;
|
||||
if (!fullCommit.test(targetSha || '')) {
|
||||
throw new Error('protected main did not resolve to one full commit SHA');
|
||||
}
|
||||
await requireMainContainment(targetSha);
|
||||
}
|
||||
core.setOutput('target_sha', targetSha);
|
||||
core.info(`Resolved protected-main coverage repair target ${targetSha}.`);
|
||||
|
||||
- name: Check out exact protected-main target
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
ref: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
|
||||
- name: Verify checked-out repair target
|
||||
env:
|
||||
TARGET_SHA: ${{ steps.resolve-target.outputs.target_sha }}
|
||||
run: test "$(git rev-parse HEAD)" = "$TARGET_SHA"
|
||||
|
||||
- name: Set up Go
|
||||
id: setup-go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
- name: Restore exact target coverage profile
|
||||
id: target-cache
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
- name: Validate existing exact target profile
|
||||
if: steps.target-cache.outputs.cache-hit == 'true'
|
||||
run: |
|
||||
set -eu
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Install archive tooling for cold repair
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
if command -v zip >/dev/null && command -v unzip >/dev/null; then
|
||||
echo "zip and unzip are already available"
|
||||
else
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y zip unzip
|
||||
fi
|
||||
|
||||
- name: Recompute complete target coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
env:
|
||||
DWS_PACKAGE_VERSION: 0.0.0-test
|
||||
run: |
|
||||
set -euo pipefail
|
||||
go test -count=1 -p 1 \
|
||||
-coverprofile=coverage-cache.txt \
|
||||
-covermode=atomic \
|
||||
./ ./cmd/... ./internal/... ./skills/...
|
||||
test -s coverage-cache.txt
|
||||
test "$(head -n 1 coverage-cache.txt)" = "mode: atomic"
|
||||
|
||||
- name: Save exact protected-main coverage profile
|
||||
if: steps.target-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
|
||||
# Cache uploads are fail-open warnings. A lookup-only restore plus the
|
||||
# explicit cache-hit assertion makes an absent or partial key fail hard.
|
||||
- name: Verify exact protected-main coverage cache exists
|
||||
id: target-cache-verification
|
||||
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4
|
||||
with:
|
||||
path: coverage-cache.txt
|
||||
key: dws-coverage-full-v2-${{ steps.resolve-target.outputs.target_sha }}-go${{ steps.setup-go.outputs.go-version }}
|
||||
lookup-only: true
|
||||
fail-on-cache-miss: true
|
||||
|
||||
- name: Require exact protected-main coverage cache
|
||||
env:
|
||||
EXACT_CACHE_HIT: ${{ steps.target-cache-verification.outputs.cache-hit }}
|
||||
run: test "$EXACT_CACHE_HIT" = true
|
||||
@@ -14,9 +14,12 @@ jobs:
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const webhook = process.env.DINGTALK_WEBHOOK;
|
||||
if (!webhook) {
|
||||
console.log('⚠️ DINGTALK_WEBHOOK not set, skipping notification');
|
||||
const webhooks = [
|
||||
process.env.DINGTALK_WEBHOOK,
|
||||
process.env.DINGTALK_WEBHOOK_SECONDARY
|
||||
].filter(Boolean);
|
||||
if (webhooks.length === 0) {
|
||||
console.log('⚠️ No DingTalk webhook configured, skipping notification');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -39,12 +42,15 @@ jobs:
|
||||
}
|
||||
};
|
||||
|
||||
await fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
});
|
||||
await Promise.all(webhooks.map(webhook =>
|
||||
fetch(webhook, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(message)
|
||||
})
|
||||
));
|
||||
|
||||
console.log('✅ DingTalk notification sent');
|
||||
console.log(`✅ DingTalk notification sent to ${webhooks.length} webhook(s)`);
|
||||
env:
|
||||
DINGTALK_WEBHOOK: ${{ secrets.DINGTALK_WEBHOOK }}
|
||||
DINGTALK_WEBHOOK_SECONDARY: ${{ secrets.DINGTALK_WEBHOOK_SECONDARY }}
|
||||
|
||||
@@ -1112,6 +1112,9 @@ jobs:
|
||||
needs: [release-contract, release-validation, release, verify-darwin-signatures]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
outputs:
|
||||
coverage_baseline_required: ${{ steps.seal-formula.outputs.coverage_baseline_required }}
|
||||
coverage_baseline_commit: ${{ steps.seal-formula.outputs.coverage_baseline_commit }}
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
@@ -1495,6 +1498,7 @@ jobs:
|
||||
DWS_GIT_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com
|
||||
|
||||
- name: Seal Formula-only Code Admission contexts
|
||||
id: seal-formula
|
||||
if: ${{ github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
@@ -1515,6 +1519,8 @@ jobs:
|
||||
const sourcePath = channel === "stable"
|
||||
? "dist/homebrew/dingtalk-workspace-cli.rb"
|
||||
: "dist/homebrew/dingtalk-workspace-cli-beta.rb";
|
||||
core.setOutput("coverage_baseline_required", "false");
|
||||
core.setOutput("coverage_baseline_commit", "");
|
||||
const expectedMessage = channel === "stable"
|
||||
? `chore: update formula for ${version} [skip ci]`
|
||||
: `chore: update beta formula for ${version} [skip ci]`;
|
||||
@@ -1650,6 +1656,11 @@ jobs:
|
||||
},
|
||||
});
|
||||
}
|
||||
core.setOutput("coverage_baseline_required", "true");
|
||||
core.setOutput("coverage_baseline_commit", commit);
|
||||
core.info(
|
||||
`Formula-only Code Admission is sealed for ${commit}; the independent confirmation job will dispatch its exact-SHA cache producer.`,
|
||||
);
|
||||
|
||||
- name: Reverify exact immutable npm package
|
||||
run: ./scripts/release/verify-package-managers.sh --npm-only --expected-version "$RELEASE_VERSION"
|
||||
@@ -2177,6 +2188,117 @@ jobs:
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
|
||||
coverage-baseline-confirmation:
|
||||
name: Confirm Formula coverage baseline
|
||||
# Once Formula sealing has exposed a target SHA, later publication
|
||||
# verification failures must not orphan its exact-main cache producer.
|
||||
if: ${{ !cancelled() && (needs.publish-release.result == 'success' || needs.publish-release.outputs.coverage_baseline_required == 'true') }}
|
||||
needs: publish-release
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 35
|
||||
permissions:
|
||||
checks: write
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require exact Formula cache acknowledgement
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
BASELINE_REQUIRED: ${{ needs.publish-release.outputs.coverage_baseline_required }}
|
||||
FORMULA_COMMIT: ${{ needs.publish-release.outputs.coverage_baseline_commit }}
|
||||
with:
|
||||
script: |
|
||||
const rawRequired = process.env.BASELINE_REQUIRED;
|
||||
if (!['true', 'false'].includes(rawRequired)) {
|
||||
throw new Error(`Formula baseline requirement is invalid: ${rawRequired || 'empty'}`);
|
||||
}
|
||||
const required = rawRequired === 'true';
|
||||
const targetSha = process.env.FORMULA_COMMIT;
|
||||
if (!required) {
|
||||
if (targetSha) {
|
||||
throw new Error('Formula baseline outputs are inconsistent for a no-op publication');
|
||||
}
|
||||
core.info('Formula was already current; no new exact-SHA cache acknowledgement is required.');
|
||||
return;
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/.test(targetSha)) {
|
||||
throw new Error('Formula baseline target output is malformed');
|
||||
}
|
||||
const expectedExternalId = `release-${context.runId}-${targetSha}`;
|
||||
let promotionCheck;
|
||||
try {
|
||||
const created = await github.rest.checks.create({
|
||||
...context.repo,
|
||||
name: 'Coverage Baseline Cache',
|
||||
head_sha: targetSha,
|
||||
status: 'queued',
|
||||
external_id: expectedExternalId,
|
||||
output: {
|
||||
title: 'Waiting for exact-SHA baseline promotion',
|
||||
summary:
|
||||
'The independent release governance job is waiting for the default-branch cache producer.',
|
||||
},
|
||||
});
|
||||
promotionCheck = created.data;
|
||||
await github.rest.repos.createDispatchEvent({
|
||||
...context.repo,
|
||||
event_type: 'coverage-baseline-promote',
|
||||
client_payload: {
|
||||
target_sha: targetSha,
|
||||
source_run_id: String(context.runId),
|
||||
check_run_id: String(promotionCheck.id),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (promotionCheck) {
|
||||
try {
|
||||
await github.rest.checks.update({
|
||||
...context.repo,
|
||||
check_run_id: promotionCheck.id,
|
||||
status: 'completed',
|
||||
conclusion: 'failure',
|
||||
completed_at: new Date().toISOString(),
|
||||
output: {
|
||||
title: 'Coverage baseline dispatch failed',
|
||||
summary: `Release could not dispatch the exact-SHA producer: ${error.message}`,
|
||||
},
|
||||
});
|
||||
} catch (cleanupError) {
|
||||
core.error(
|
||||
`Could not close failed cache acknowledgement ${promotionCheck.id}: ${cleanupError.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const checkRunId = promotionCheck.id;
|
||||
for (let attempt = 1; attempt <= 180; attempt += 1) {
|
||||
const {data: currentCheck} = await github.rest.checks.get({
|
||||
...context.repo,
|
||||
check_run_id: checkRunId,
|
||||
});
|
||||
if (
|
||||
currentCheck.head_sha !== targetSha ||
|
||||
currentCheck.name !== 'Coverage Baseline Cache' ||
|
||||
currentCheck.external_id !== expectedExternalId ||
|
||||
currentCheck.app?.slug !== 'github-actions'
|
||||
) {
|
||||
throw new Error('Formula baseline promotion acknowledgement changed identity');
|
||||
}
|
||||
if (currentCheck.status === 'completed') {
|
||||
if (currentCheck.conclusion !== 'success') {
|
||||
throw new Error(
|
||||
`Formula baseline promotion failed with ${currentCheck.conclusion || 'unknown'}`,
|
||||
);
|
||||
}
|
||||
core.info(`Formula baseline promotion completed for ${targetSha}.`);
|
||||
return;
|
||||
}
|
||||
if (attempt < 180) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
}
|
||||
}
|
||||
throw new Error(`Formula baseline promotion timed out for ${targetSha}`);
|
||||
|
||||
release-delivery-gate:
|
||||
name: Release delivery gate
|
||||
if: ${{ !cancelled() }}
|
||||
@@ -2189,6 +2311,7 @@ jobs:
|
||||
- verify-darwin-signatures
|
||||
- publish-release
|
||||
- publish-channels
|
||||
- coverage-baseline-confirmation
|
||||
- mirror-gitee-release
|
||||
- repair-npm
|
||||
- repair-channel
|
||||
@@ -2211,6 +2334,7 @@ jobs:
|
||||
DARWIN_SIGNATURE_RESULT: ${{ needs.verify-darwin-signatures.result }}
|
||||
PUBLISH_RELEASE_RESULT: ${{ needs.publish-release.result }}
|
||||
PUBLISH_CHANNELS_RESULT: ${{ needs.publish-channels.result }}
|
||||
COVERAGE_BASELINE_CONFIRMATION_RESULT: ${{ needs.coverage-baseline-confirmation.result }}
|
||||
MIRROR_GITEE_RESULT: ${{ needs.mirror-gitee-release.result }}
|
||||
REPAIR_NPM_RESULT: ${{ needs.repair-npm.result }}
|
||||
REPAIR_CHANNEL_RESULT: ${{ needs.repair-channel.result }}
|
||||
@@ -2234,6 +2358,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" success
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" success
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" success
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" success
|
||||
if test "$GITEE_FALLBACK_ENABLED" = true; then
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" success
|
||||
else
|
||||
@@ -2273,6 +2398,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2294,6 +2420,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
@@ -2309,6 +2436,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-channel "$REPAIR_CHANNEL_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
@@ -2323,6 +2451,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
@@ -2337,6 +2466,7 @@ jobs:
|
||||
require_result verify-darwin-signatures "$DARWIN_SIGNATURE_RESULT" skipped
|
||||
require_result publish-release "$PUBLISH_RELEASE_RESULT" skipped
|
||||
require_result publish-channels "$PUBLISH_CHANNELS_RESULT" skipped
|
||||
require_result coverage-baseline-confirmation "$COVERAGE_BASELINE_CONFIRMATION_RESULT" skipped
|
||||
require_result mirror-gitee-release "$MIRROR_GITEE_RESULT" skipped
|
||||
require_result repair-npm "$REPAIR_NPM_RESULT" skipped
|
||||
require_cloud_jobs_skipped
|
||||
@@ -2698,9 +2828,11 @@ jobs:
|
||||
;;
|
||||
compatibility)
|
||||
test -n "$PREVIOUS_STABLE"
|
||||
./scripts/policy/check-command-compatibility.sh \
|
||||
"$GITHUB_WORKSPACE/tmp/trusted-release-tooling/scripts/release/check-release-compatibility.sh" \
|
||||
--repo-root "$GITHUB_WORKSPACE" \
|
||||
--base-ref HEAD \
|
||||
--stable-ref "$PREVIOUS_STABLE"
|
||||
--stable-ref "$PREVIOUS_STABLE" \
|
||||
--candidate-ref HEAD
|
||||
;;
|
||||
e2e)
|
||||
bash scripts/dev/test-multi-profile-e2e.sh
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -44,7 +44,8 @@ Schema contract) keep separate authorities — do not merge them with
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- Today (leaf): `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- Today (non-leaf): owning Cobra command → complete `corecmd.GroupPolicy{Mode, Positionals, Recovery}` → `corecmd.ApplyGroupPolicy`; the final assembled-tree gate rejects undeclared groups and stale group declarations on leaves
|
||||
- **Declare = final Schema source**: `Flags` / `Constraints` / `Safety` / `ConstParams` / `Contract` (`corecmd.ContractDecl`; nested fields are `contract.*`)
|
||||
- Naming: `ContractDecl` is the authoring leaf declaration. "Schema" means Catalog / `ToolSpec` delivery — do not reintroduce `SchemaDecl`.
|
||||
- `Safety` uses `contract.SafetySpec` (`internal/corecmd/contract` only — no `cli.*` type alias). Its `confirmation` drives the runtime gate; `effect` / `risk` / `idempotency` are published unchanged. When `Contract` is set, convert once via `contractfinal.RegisterRuntimeContractFinal` (all callers — `corecmd.New` registers internally); assembly **pass-throughs** Final.
|
||||
@@ -60,6 +61,7 @@ Schema contract) keep separate authorities — do not merge them with
|
||||
- **Tier2** — `DeclareLeafMetadata` (helpers migration; **Shortcut may also use this path — acceptable**)
|
||||
- **Tier3** — bare Cobra (should shrink over time; reviewed exclusions where needed)
|
||||
- Long-term outlook only: broader mcpbind / fewer hand-written `Execute` bodies. **Not** a current hard requirement to delete `Shortcut.Execute` or force mcpbind.
|
||||
- Group policy is separate from the leaf tiers: `corecmd.Spec` remains leaf-only. `ApplyGroupPolicy` must not infer or enable `TraverseChildren`; parent local-flag inheritance remains an explicit owning-command surface.
|
||||
- Description declare vs delivery: construction requires `ContractDecl.Description` (evidence). Catalog delivery prefers Cobra Long → provenance `cobra_help`; without Long, declared text → `contract_final`. Title: declared first, then Short, then MCP. Do **not** read this as "declare = wire final" or dual authority.
|
||||
- **Execute** = hooks (`Validate` / `Call` / `RunE` / `PostMount`) — not a second surface authority
|
||||
- Declaration path has **no reviewed parallel fields**; migration-only `runtime_gate` annotate until `Safety` is declared
|
||||
|
||||
+128
@@ -6,6 +6,134 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.0.60-beta.2] - 2026-08-24
|
||||
|
||||
### Added
|
||||
|
||||
- **Drive permission get-setting** (#1056) — adds `dws drive permission get-setting --node <ID>` to inspect a document-space node's permission settings (permission mode, share scope, and permission policies) in one call.
|
||||
|
||||
- **Whiteboard shortcuts** (#1082) — adds strict query and confirmed update workflows with stable-target receipts and exact readback verification.
|
||||
- **Sheet shortcut hardening** (#1082) — makes worksheet listing and cell-range reads fail closed on malformed, ambiguous, or truncated responses, publishes a closed reviewed output shape, and preserves non-executing `--dry-run` previews for range reads.
|
||||
|
||||
- **Permission and member list pagination** (#1085) — `drive/doc permission
|
||||
list` and `wiki member list` now accept `--next-token` to follow the
|
||||
server-side cursor (output carries `totalCount`/`hasMore`/`nextToken`) and
|
||||
map `--limit` to `pageSize` capped at 50 instead of the rejected `maxResults
|
||||
200` path; `permission add/update/remove` and `wiki member add/update/remove`
|
||||
additionally accept a `--members` JSON array covering USER/DEPT/CONVERSATION/TAG
|
||||
grantee types. The optional `--notify` defaults to `false` and is omitted from
|
||||
the server request unless passed explicitly, so member grants no longer notify
|
||||
recipients by default. These commands also declare cursor pagination
|
||||
(`next-token`) in the Agent schema contract, mirroring the internal CLI parity
|
||||
change. Because a single batch remove can revoke access for up to 30
|
||||
USER/DEPT/CONVERSATION/TAG members — where departments, chats, and role
|
||||
groups can indirectly affect many more users — `drive/doc permission
|
||||
remove` and `wiki member remove` now declare
|
||||
`confirmation=user_required` and gate the actual tool call behind user
|
||||
confirmation (`--yes`, an interactive yes, or `--dry-run` preview); their
|
||||
confirmation-gate failure now also passes through verbatim instead of being
|
||||
reclassified as a permission-denied or unclassified error.
|
||||
|
||||
- **Agoal scorecard search-entities** — `dws agoal scorecard search-entities` searches scorecard metrics and key items by keyword, returning matching entity info (scorecard ID, entity ID, entity type, title, owning team) with optional `--page`/`--page-size` pagination.
|
||||
|
||||
- **AITable datasource shortcuts** — adds 7 shortcuts for datasource sync management (`+datasource-create`, `+datasource-update`, `+datasource-sync`, `+datasource-sync-status`, `+datasource-get-config`, `+datasource-list-sources`, `+datasource-get-fields`) and updates the `dingtalk-aitable` skill with routing rules and a new `aitable-datasource.md` reference guide.
|
||||
|
||||
- **Doc public-link and historical-version reads** — `dws doc read` forwards
|
||||
the reviewed `password` (internet-public documents with password protection)
|
||||
and `historyVersion` (read content as of a listed historical version; `0`
|
||||
denotes the document's initial version) parameters on the markdown, JSONML,
|
||||
and scope read paths via `--password` / `--version`; `dws doc +fetch` gains
|
||||
`--password` and `--version` with the same `historyVersion` forwarding, while
|
||||
`--revision` stays rejected with explicit guidance: revision is the document
|
||||
edit revision returned by JSONML reads for `+update --expected-revision`
|
||||
conditional writes, not a historical version number.
|
||||
|
||||
- **Edu & College vendor extensions** — adds five hidden vendor extension commands for education scenarios: `dws edu-contact` (school/class/family/teacher contact management), `dws edu-group` (student/class group lifecycle), `dws edu-app` (homework, notices, report cards, diplomas, class circles), `dws edu-familygroup` (family group management, child binding, app permissions), and `dws college-contact` (university dept/employee/alumni/graduate management). All route to dedicated MCP servers via `callMCPToolOnServer`.
|
||||
|
||||
- **OA approval attachment upload** — `dws oa approval attachment upload --file <path>` uploads a local file as an approval attachment in one command: it initializes the upload credential (MCP `oa/init_attachment_upload_info`), HTTP PUTs the file to OSS, then commits it (MCP `oa/commit_attachment_upload_info`). `--file-name` defaults to the file's base name and `--md5` is auto-computed when omitted.
|
||||
|
||||
- **Sheet revision changesets** — adds read-only commands for querying the current workbook revision and reviewing Agent-readable changes between revisions, with guidance for distinguishing revisions from saved history versions and safely selecting rollback targets.
|
||||
|
||||
- **Sheet floating images** — supports creating or replacing a floating image directly from a local file with `create-float-image --file` and `update-float-image --file`, while retaining the existing `--src` workflow.
|
||||
|
||||
### Changed
|
||||
|
||||
- **AiSearch and Contact shortcuts** (#1083) — adds strict people search and reviewed unified results; people results must use the live-reviewed `person` source, and exact mobile lookups normalize accepted formatting before calling the dedicated mobile interface. Agent/public discovery keeps `contact +list-roles`, `contact +list-roster-fields`, `contact +get-roster`, and incomplete Live routes unavailable rather than publishing ambiguous results, while the historical Contact CLI commands retain legacy MCP execution and real error propagation. The legacy role-list projection preserves the service's reviewed null placeholder without exposing that ambiguous row through Agent Result contracts.
|
||||
|
||||
- **Permission error guidance and error rendering** (#1085) —
|
||||
permission-denied responses now exit with the `AUTH_PERMISSION_DENIED` code
|
||||
instead of a generic business-error rendering; document/wiki-specific errors
|
||||
(the drive-specific codes `forbidden.accessDenied` / `forbidden.no.auth`,
|
||||
or the role-threshold wording like
|
||||
“需要您具备 MANAGER 及以上角色”) carry apply-permission guidance
|
||||
(`dws drive permission apply-info` / `dws drive permission apply`), while
|
||||
permission failures carrying only generic code names (`FORBIDDEN`,
|
||||
`NO_PERMISSION` — also returned by attendance and event-subscription tools)
|
||||
or other products' wording keep their product-specific or
|
||||
product-neutral suggestion instead of a misleading document-permission hint;
|
||||
member-validation failures such as
|
||||
“用户不存在/不属于当前组织” are classified as tool errors with a
|
||||
`--members`-with-`corpId` suggestion instead of a misleading
|
||||
resource-not-found error; business error output now surfaces the backend
|
||||
message with `code`/`logId` appended for traceability; and the
|
||||
`update_permission` / `remove_permission` / `update_member` /
|
||||
`remove_member` tools — whose servers return a literal `null` on successful
|
||||
no-payload writes — now render `{}` so downstream JSON consumers do not fail
|
||||
parsing `null`; other tools keep raw `null` output unchanged.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy global slot recovery** — recovers a rejected identity refresh from the legacy global keychain slot when the organization mirror is absent, with strict corp/user matching so blank-user legacy tokens only recover for single-account organizations.
|
||||
|
||||
|
||||
## [1.0.60-beta.1] - 2026-08-21
|
||||
|
||||
### Changed
|
||||
|
||||
- **OA, DING, and Report shortcuts** — hardens response, identity, pagination, and confirmation contracts; publishes verified form search, receiver status, and report read workflows while withholding shortcuts that lack trustworthy downstream evidence.
|
||||
|
||||
- **Stable release sealing** — directly preparing a stable release now renders and archives release fragments merged after its beta baseline, avoiding a forced extra beta solely to consume pending notes.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Calendar empty windows** (#1074) — returns a legitimate empty result when the service emits its exact exhausted empty-event sentinel.
|
||||
- **Task update verification** (#1074) — compares due-time readback as exact milliseconds so committed updates are no longer reported as failures.
|
||||
- **Comment reaction validation** (#1074) — narrows accepted reaction input to reviewed DingTalk emoji names and rejects Unicode emoji and unsupported names such as `like` and `heart` before the RPC.
|
||||
|
||||
- **OAuth refresh falls back to the organization mirror** — when the server rejects the
|
||||
current identity's `refresh_token` with the reviewed `invalidParameter.authCode.notFound`
|
||||
business code, `dws` now retries once with the still-valid token mirrored in the same
|
||||
organization's slot (same corp, matching or backfilled user identity) before giving up,
|
||||
and writes the rotated credential back to both the identity and the organization slots so
|
||||
the fallback stays usable on later refreshes. Transient failures and direct-mode HTTP
|
||||
rejections without a reviewed business code do not trigger the fallback.
|
||||
|
||||
|
||||
## [1.0.59] - 2026-08-20
|
||||
|
||||
This release promotes the sealed `v1.0.59-beta.5` contents to stable.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat personal emotions** — adds commands to list, send, and favorite the current user's personal favorite emotions.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage.
|
||||
|
||||
- **Shortcut functional workflows** — fixes Drive preview accuracy, AITable write verification and deletion accounting, Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
## [1.0.59-beta.5] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
- **Chat personal emotions** — adds `chat emotion list`, `chat emotion send`, and `chat emotion favorite` for current-user personal favorite emotion listing, sending, and favoriting.
|
||||
|
||||
- **Minutes, DingTalk tasks, and Wiki parameter aliases** — adds reviewed parameter-name normalization, ambiguity guards, and end-to-end payload coverage for the three products.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut functional workflows** (#1050) — fixes truthful Drive push/sync previews, strict AITable write verification and deletion accounting, lossless Wiki feeds, and false-success handling across task, Contact, Minutes, and Wiki operations.
|
||||
|
||||
|
||||
## [1.0.59-beta.4] - 2026-08-20
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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.4"
|
||||
version "1.0.60-beta.2"
|
||||
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.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "f788467e9979c70ef210b411ac915b1506ea77ffa496e26b53cfa99650158721"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "e7776807f0664cbf0d0728cc236f2415c0981eb8d6557a897d2eeee708641b1d"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a01988709c0dc99dd5874859eb265ba08a6fda412a7ead8303c68e61d2a8b195"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "3004474df3cfb529719348f02c9f2f39afa88f0fca469fe8303a9ebe0f3a0034"
|
||||
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.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "8e1a993b2137a082a8cc1d9535dfc2d7b3e4399c76d295840f9dc1f15cca7a0d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "6386885d10f149c8c555031dda4cf07bf34e1e9daad61d4cd948b92d3c7b7bad"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "26e4cd72cfb96b38ef808863391b81a5c45c3170bca56b5eac457fc601b000c5"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "5c94c2af269d2fe5a79a400d4fa3af267a86d6ab21b01a24ede1d29514a6eaef"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59-beta.4/dws-skills.zip"
|
||||
sha256 "a75107bdc14b5476e097842acc92f798301d8ffb59de9ade01f863d166a89435"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.60-beta.2/dws-skills.zip"
|
||||
sha256 "c3bd917f1b44a978ba2a9fbe95c5d0910ccf75f870f1c9b0dc356262ab1080c5"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
class DingtalkWorkspaceCli < Formula
|
||||
desc "Automate DingTalk workspace tasks from the terminal"
|
||||
homepage "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli"
|
||||
version "1.0.58"
|
||||
version "1.0.59"
|
||||
license "Apache-2.0"
|
||||
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-arm64.tar.gz"
|
||||
sha256 "7d98599f90cae9d42b51ff2863efc87dbfb4a3176ff3c84fc2216110c0157a70"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-arm64.tar.gz"
|
||||
sha256 "61135a2a9286204ce060847e653c63c1e9784a0fa631bb7e0563b90628762a35"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-darwin-amd64.tar.gz"
|
||||
sha256 "4c12e35e5bf7e0905812cd42dc94a5345068a2c16e306bb50b13c5c78b5cb95d"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-darwin-amd64.tar.gz"
|
||||
sha256 "fd14b0b1a1475891fb243bf6453857a1044ab5a40bcf7dc1c7c795f57e5b03ba"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-arm64.tar.gz"
|
||||
sha256 "5ef6bde24bc3db6a11a0f1d0b3343a048956b2cbcf6cd3409a037fb6ba425489"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-arm64.tar.gz"
|
||||
sha256 "5bfe9ac7d1798b028f0fad579bbdffec5898e2fb16ee36f5766ab58e208abd50"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-linux-amd64.tar.gz"
|
||||
sha256 "3ccadcc6f070a39d2b2ba20429a4fcdc2f21639bf79f34361dc7d16f501bfda6"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-linux-amd64.tar.gz"
|
||||
sha256 "be1eb9a1f8fc5048e578b5b0bde212fc90baca0f289236c7c333d824bd869cf3"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58/dws-skills.zip"
|
||||
sha256 "2626debc21c3daadfd155b4c167b2219b97e801398fe4441a8b48138960ab264"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.59/dws-skills.zip"
|
||||
sha256 "7ce5c3ab6f6a367407f64971bc5ff96cfcdfade2c1a10d326144b17c7b25a57e"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -10,7 +10,7 @@ SCHEMA_META_INDEX_OUTPUT ?= artifacts/schema_meta_index.gob
|
||||
POLICY_ENV = DWS_POLICY_TMPDIR="$(DWS_POLICY_TMPDIR)" GOTMPDIR="$(POLICY_GOTMPDIR)"
|
||||
GO_SOURCE_LIST = git ls-files -z --cached --others --exclude-standard -- '*.go'
|
||||
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
.PHONY: all help build rebuild test test-plan test-auth-legacy-compat shortcut-public-e2e-proof lint format-check fmt policy edition-test interface-integrity authoritative-interface-integrity coverage-gate coverage-gate-platform update-interface-baseline reset-interface-baseline schema-compatibility skill-command-integrity skill-context-budget multi-im-skill-chain-integrity cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema fetch-mcp-metadata generate-schema-catalog package release release-pre release-stable changelog-pre changelog-stable publish-homebrew-formula setup-hooks
|
||||
|
||||
all: setup-hooks fmt lint build test rebuild
|
||||
|
||||
@@ -20,6 +20,7 @@ help:
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify CI test and full-suite coverage package plans cover their scopes exactly once\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\n"
|
||||
@printf " make shortcut-public-e2e-proof - Prove every reviewed Devdoc/HRbrain/PAT public Shortcut through exact and owning raw execution\n"
|
||||
@printf " make lint - Run formatting checks, go vet, and staticcheck\n"
|
||||
@printf " make format-check - Check all repository Go source files with gofmt\n"
|
||||
@printf " make fmt - Format all repository Go source files\n"
|
||||
@@ -62,6 +63,9 @@ test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
shortcut-public-e2e-proof: build
|
||||
@GO="$(GO)" DWS_PACKAGE_VERSION="$(DWS_PACKAGE_VERSION)" ./scripts/policy/check-shortcut-public-e2e-proof.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -84,7 +88,7 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy: test-auth-legacy-compat
|
||||
policy: test-auth-legacy-compat shortcut-public-e2e-proof
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
+1
-1
@@ -19,7 +19,7 @@
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **共创阶段**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
> **钉钉 DWS CLI 已全面开放,欢迎使用**:本项目涉及钉钉企业数据访问,需企业管理员授权后方可使用。欢迎加入钉钉 DWS 共创群获取支持与最新动态。详见下方 [开始使用](#开始使用)。
|
||||
>
|
||||
> <img src="https://img.alicdn.com/imgextra/i1/O1CN01WJyAsJ1prD2ovQACM_!!6000000005413-2-tps-718-720.png" alt="dws 开源沟通群二维码" width="150">
|
||||
|
||||
|
||||
+63
-5
@@ -13,13 +13,71 @@ if (!fs.existsSync(binaryPath)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = childProcess.spawnSync(binaryPath, process.argv.slice(2), {
|
||||
// Interactive commands must remain in the terminal's foreground session so
|
||||
// prompts can use /dev/tty. Non-interactive launches use a separate process
|
||||
// group, allowing a signal sent only to this wrapper to reach the full vendor
|
||||
// process tree exactly once.
|
||||
const isolateVendorProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
|
||||
const child = childProcess.spawn(binaryPath, process.argv.slice(2), {
|
||||
stdio: "inherit",
|
||||
detached: isolateVendorProcessGroup,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error(result.error.message);
|
||||
process.exit(1);
|
||||
let spawnFailed = false;
|
||||
let forwardedSignal = null;
|
||||
const forwardedSignals = ["SIGINT", "SIGTERM"];
|
||||
|
||||
function forwardSignal(signal) {
|
||||
forwardedSignal = signal;
|
||||
if (child.exitCode === null && child.signalCode === null) {
|
||||
if (process.platform === "win32") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
if (!isolateVendorProcessGroup) {
|
||||
// Ctrl-C is generated for the whole foreground process group, including
|
||||
// the vendor. SIGTERM is not terminal-generated and still needs an
|
||||
// explicit handoff when a process manager targets only this wrapper.
|
||||
if (signal === "SIGTERM") {
|
||||
child.kill(signal);
|
||||
}
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// detached makes the vendor PID the leader of its POSIX process group.
|
||||
// Signal the whole group so any subprocesses inherit the same shutdown.
|
||||
process.kill(-child.pid, signal);
|
||||
} catch (error) {
|
||||
// The group may have completed between the state check and kill.
|
||||
if (error.code !== "ESRCH") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
process.exit(result.status === null ? 1 : result.status);
|
||||
const signalHandlers = new Map(
|
||||
forwardedSignals.map((signal) => [signal, () => forwardSignal(signal)]),
|
||||
);
|
||||
for (const signal of forwardedSignals) {
|
||||
process.on(signal, signalHandlers.get(signal));
|
||||
}
|
||||
|
||||
child.on("error", (error) => {
|
||||
spawnFailed = true;
|
||||
console.error(error.message);
|
||||
});
|
||||
|
||||
child.on("close", (code, signal) => {
|
||||
for (const forwarded of forwardedSignals) {
|
||||
process.removeListener(forwarded, signalHandlers.get(forwarded));
|
||||
}
|
||||
|
||||
const exitSignal = forwardedSignal || signal;
|
||||
if (exitSignal && process.platform !== "win32") {
|
||||
process.kill(process.pid, exitSignal);
|
||||
return;
|
||||
}
|
||||
process.exitCode = spawnFailed || code === null ? 1 : code;
|
||||
});
|
||||
|
||||
+168
-4
@@ -62,9 +62,124 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Reviewer Router GitHub App
|
||||
|
||||
Reviewer requests and native auto-merge intentionally use different
|
||||
identities. The base-owned `pull_request_target` workflow may use its built-in
|
||||
`GITHUB_TOKEN` to request reviewers, but it must mint a dedicated GitHub App
|
||||
installation token before enabling auto-merge. GitHub suppresses most workflow
|
||||
events created by the built-in token; using it for auto-merge prevents the
|
||||
merge commit's `push` workflows from running and leaves the exact-SHA Coverage
|
||||
baseline without a trusted main-scoped producer.
|
||||
|
||||
Configure the dedicated App before merging a workflow revision that requires
|
||||
it:
|
||||
|
||||
- install it only on `DingTalk-Real-AI/dingtalk-workspace-cli`;
|
||||
- grant only `Contents: read and write` and `Pull requests: read and write`;
|
||||
- set repository variable `REVIEWER_ROUTER_APP_CLIENT_ID` to its client ID;
|
||||
- set `REVIEWER_ROUTER_APP_SLUG` to its exact lowercase slug;
|
||||
- set repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY` to its private key;
|
||||
- create one active repository branch ruleset named `main-merge-writers`,
|
||||
targeting only `refs/heads/main`, with exactly one `Restrict updates` rule
|
||||
(`update_allows_fetch_and_merge: false`). GitHub may project that strict
|
||||
value through the read APIs as `{type: "update"}` with `parameters` omitted;
|
||||
consumers accept only that exact omission or a one-field `parameters` object
|
||||
containing explicit boolean `false`, and reject every other present shape or
|
||||
value. They then bind the same ruleset node through GraphQL and require its
|
||||
non-null `updateAllowsFetchAndMerge` value to be exactly `false`;
|
||||
- give that ruleset exactly two bypass actors: the Reviewer Router App as an
|
||||
`Integration` in `pull_request` mode, and `PeterGuy326` (ID `47820304`) in
|
||||
`always` mode for Formula publication and break-glass recovery;
|
||||
- never give the App bypass on `main-protection`, `main-quality`, or any other
|
||||
ruleset, and never reuse `HOMEBREW_PR_TOKEN`,
|
||||
`RELEASE_GOVERNANCE_TOKEN`, or a personal token for Reviewer Router.
|
||||
|
||||
The workflow limits each minted token to the current repository, requests the
|
||||
two permissions explicitly, and lets the token action revoke it at job end.
|
||||
It also requires the minted App slug to equal the reviewed repository variable;
|
||||
there is no `GITHUB_TOKEN` fallback. Before reading App credentials, the
|
||||
base-owned workflow revalidates the event's exact base/head and uses its
|
||||
built-in token only to disable an existing request owned by
|
||||
`github-actions[bot]` or one whose title or merge metadata requests that GitHub
|
||||
skip workflows. A mint or permission failure therefore leaves that PR
|
||||
manual-merge only. The built-in token's `Contents: write` permission is
|
||||
isolated to this trusted cleanup job and is never used to enable auto-merge;
|
||||
review routing keeps `Contents: read`. Existing requests owned by a human or
|
||||
another non-built-in identity are replaced with the exact dedicated-App
|
||||
request after token minting. Only an already App-owned request with the fixed
|
||||
headline/body is preserved. The required `Test` context reads the live
|
||||
repository settings and applied rulesets, verifies the exact writer-rule
|
||||
shape, and requires its own built-in Actions identity to report
|
||||
`current_user_can_bypass: never`. Before enabling or reconciling auto-merge,
|
||||
the minted App independently requires `pull_requests_only` on that writer rule
|
||||
and `never` on every other active main ruleset. These identity-relative checks
|
||||
remain available to low-privilege tokens; GitHub deliberately hides the full
|
||||
`bypass_actors` list from callers without ruleset-write access. Operators must
|
||||
therefore inspect that list during rollout and keep it at the exact two actors
|
||||
above. The required `Test` context then briefly waits for the concurrent
|
||||
router takeover and accepts only a null request or the configured App owner
|
||||
with exact fixed metadata. A null request is safe for this failure mode because
|
||||
the built-in Actions identity cannot pass the writer rule; other permitted
|
||||
identities emit either a protected-main push or the trusted closed-PR repair.
|
||||
Draft PRs skip this identity check; the explicit `ready_for_review` trigger
|
||||
reruns admission when they become merge-eligible,
|
||||
while `edited` and `auto_merge_enabled` rerun both workflows when the PR title
|
||||
or merge request changes. A human `auto_merge_disabled` event reruns CI without
|
||||
silently re-enabling the request, leaving it available only to the designated
|
||||
break-glass identity. The required `Test` context rejects GitHub workflow-skip
|
||||
directives in the PR title or an existing auto-merge request and verifies the
|
||||
repository's reviewed `MERGE_MESSAGE` title plus `PR_TITLE` or `BLANK` body
|
||||
defaults. GitHub does not expose those merge-related settings to the read-only
|
||||
admission token: the classifier accepts only both exact reviewed values or the
|
||||
complete omission of both properties, and rejects partial omission, `null`, or
|
||||
any other value. Before any enable or reconcile mutation, the dedicated App's
|
||||
current-repository token (which has `Contents: write`) must observe both exact
|
||||
reviewed values. The dedicated App binds the mutation to the exact head OID and
|
||||
supplies a fixed safe headline and body, so GitHub cannot copy an unsafe PR
|
||||
title into its merge commit.
|
||||
After enabling, the workflow requires the owner to equal the token action's
|
||||
exact `<app-slug>[bot]` output. If the event base/head changes during the
|
||||
mutation window, it removes only that App-owned request and fails the run.
|
||||
The break-glass publisher must preserve a safe final commit message;
|
||||
`[skip ci]`, `[ci skip]`, `[no ci]`, `[skip actions]`,
|
||||
`[actions skip]`, and a `skip-checks: true` trailer are forbidden outside the
|
||||
release-controlled Formula-only path below.
|
||||
|
||||
GitHub may suppress `pull_request_target` entirely for security-sensitive head
|
||||
branch names, including names that look like commit SHAs. Such a PR receives
|
||||
neither App takeover nor the closed-event repair. Rename the head branch for
|
||||
the normal path; if break-glass merge is unavoidable, preserve a safe final
|
||||
message so the protected-main push CI remains the authoritative producer.
|
||||
|
||||
After installing the App, the protected-main push that deploys this workflow
|
||||
runs reconciliation automatically. The job enumerates open, ready `main` PRs
|
||||
with any non-App owner, unsafe App metadata, or workflow-skip metadata. It
|
||||
revalidates each base/head, converges a safe request to the exact dedicated-App
|
||||
owner and fixed message, and leaves a workflow-skipping request disabled for
|
||||
manual correction. It never enables auto-merge where the request was already
|
||||
null. A mid-migration failure leaves the affected PR disabled for a fresh
|
||||
routing event or break-glass merge. One PR failure is recorded
|
||||
without preventing later legacy owners from being attempted; the batch ends
|
||||
red with a per-PR summary. Manually dispatch `Reviewer routing` from `main`
|
||||
until the failed count is zero.
|
||||
|
||||
A PR that introduces or rotates this identity still runs the old base-owned
|
||||
router. Install/configure the App and activate the exact writer ruleset first;
|
||||
this blocks its legacy `github-actions[bot]` request from writing `main`. After
|
||||
the governance PR's final push, disable that old request, confirm the live
|
||||
settings/ruleset contract and all required checks are green for the exact head,
|
||||
then have only `PeterGuy326` merge that head with the repository-generated safe
|
||||
merge message. Verify the resulting merge SHA has a `CI` run with `event=push`,
|
||||
a successful `Coverage` context, and an exact-SHA baseline cache under
|
||||
`refs/heads/main`. Confirm automatic reconciliation reports zero failures and
|
||||
zero non-App owners. Finally use a normal canary PR to verify that the dedicated
|
||||
App is both `enabledBy` and `mergedBy`, and that the same post-merge chain
|
||||
repeats before declaring the rollout complete.
|
||||
|
||||
## Homebrew Formula Delivery
|
||||
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` to update
|
||||
Official releases use the designated `HOMEBREW_PR_TOKEN` identity to update
|
||||
exactly one tracked Formula after the immutable GitHub assets and their
|
||||
checksums have passed verification. The publisher validates the rendered Ruby,
|
||||
commits only the configured Formula path, never force-pushes `main`, and retries
|
||||
@@ -72,11 +187,60 @@ from a fresh clone up to three times when `main` advances concurrently. Normal
|
||||
stable and beta releases do not create a Formula PR or run a permission
|
||||
canary. The workflow uses the existing repository-scoped
|
||||
`HOMEBREW_PR_TOKEN` release identity because GitHub does not allow its built-in
|
||||
Actions App to bypass this repository's rulesets. That identity is the sole
|
||||
user bypass actor on the two default-branch rulesets. The workflow creates the
|
||||
Actions App to bypass this repository's rulesets. Its owner is the designated
|
||||
always-bypass actor for controlled Formula publication and break-glass recovery,
|
||||
including on `main-merge-writers`. The workflow creates the
|
||||
nine Code Admission checks for the Formula-only commit only after proving its
|
||||
sole parent already has all nine successful checks and the committed Formula
|
||||
exactly matches this release's verified bytes.
|
||||
exactly matches this release's verified bytes. Formula commits retain
|
||||
`[skip ci]`, so the sealing step exposes only the reviewed commit identity to
|
||||
an independent confirmation job. That job creates the
|
||||
`Coverage Baseline Cache` acknowledgement and emits the reviewed
|
||||
`coverage-baseline-promote` repository dispatch. The default-branch
|
||||
`Coverage Baseline Promotion` workflow independently verifies the exact
|
||||
single-parent Formula commit, both parent and target admission contexts, and
|
||||
default-branch containment before checking out the target. It restores only
|
||||
the exact parent profile, recomputes the complete profile if that cache is
|
||||
absent, and saves the Formula SHA under the `main` cache scope. Because the
|
||||
cache save action treats upload errors as warnings, a second lookup must report
|
||||
`cache-hit=true` for the exact target key before the producer succeeds. The
|
||||
promotion completes the unique acknowledgement, and the confirmation job
|
||||
waits for that exact check-run ID. npm and mirror publication depend only on
|
||||
the immutable release job, so a transient
|
||||
cache-service failure cannot strand an otherwise valid release between
|
||||
channels; the final release-delivery gate still fails until the exact cache is
|
||||
confirmed. Once Formula sealing exposes the target SHA, the confirmation job
|
||||
also runs when a later immutable-package recheck fails, so a post-push failure
|
||||
cannot orphan the producer. Rerun the failed promotion/confirmation path after
|
||||
repairing the producer. Never add a prefix `restore-keys` fallback to this path.
|
||||
|
||||
`Coverage Baseline Repair` is the independent safety net for every merged PR.
|
||||
Its base-owned `pull_request_target: closed` job never checks out or executes PR
|
||||
content: it binds the closed event's PR number and stable head SHA to the
|
||||
current merged-PR facts (`merged_at`, `base.ref`, and `merge_commit_sha`) and
|
||||
proves that merge commit is contained in `main`. It deliberately does not
|
||||
compare REST `base.sha`, because that field follows the live base branch and
|
||||
can move after the merge. Only then does it emit a
|
||||
`coverage-baseline-repair` repository dispatch. Workflow-skip directives alone
|
||||
do not suppress `pull_request_target`, subject to GitHub's separate
|
||||
security-sensitive branch-name restriction described above. The low-trust
|
||||
trigger is forbidden from writing the default-branch cache directly. Before
|
||||
dispatching, it gives Actions event delivery one minute to expose a run from
|
||||
the exact protected `.github/workflows/ci.yml` workflow and exits if that normal producer already
|
||||
owns the SHA, avoiding a duplicate full-suite run. A successful CI producer
|
||||
must hard-verify its exact cache key. If that run instead completes with any
|
||||
non-success conclusion, a separate base-owned `workflow_run` dispatcher binds
|
||||
the exact workflow ID/path, run ID/attempt, conclusion, repository, branch, and
|
||||
head SHA before requesting repair. `workflow_run` also has read-only
|
||||
default-branch cache access, so both dispatchers use the reviewed
|
||||
`repository_dispatch` exception. The dispatched default-branch producer
|
||||
revalidates the corresponding merged-PR or failed-CI identity before checkout,
|
||||
restores only the exact target key, recomputes the complete profile on a miss,
|
||||
and verifies `cache-hit=true` after saving. An hourly schedule refreshes the
|
||||
event-time `main` SHA after direct break-glass pushes or cache eviction;
|
||||
`workflow_dispatch` provides the same current-main repair on demand. The
|
||||
dedicated App identity remains mandatory because events created by the built-in
|
||||
`GITHUB_TOKEN` can suppress both the main push and the closed-PR event.
|
||||
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
|
||||
+151
-12
@@ -147,13 +147,68 @@ maintainer pool. A current-head approval or change request is preserved; after
|
||||
a new push, stale activity does not suppress a fresh request, and an
|
||||
outstanding change requester is preferred for continuity.
|
||||
|
||||
The branch ruleset keeps one human approval and all nine strict required
|
||||
contexts, and requires someone other than the latest pusher to approve after
|
||||
the most recent head update. Repository auto-merge is enabled for ready PRs,
|
||||
so a PR merges after that approval and the current revision's nine checks are
|
||||
green. If `main` advances, strict checks rerun before merge. The reviewer
|
||||
router is orchestration, not a quality context, and must not be added to the
|
||||
ruleset.
|
||||
The branch rulesets keep one human approval and all nine strict required
|
||||
contexts, require someone other than the latest pusher to approve after the
|
||||
most recent head update, and restrict `main` updates to the dedicated Reviewer
|
||||
Router App in pull-request mode plus the designated Formula publisher in
|
||||
always/break-glass mode. Repository auto-merge is enabled for ready PRs, so the
|
||||
App-owned request merges after that approval and the current revision's nine
|
||||
checks are green. If `main` advances, strict checks rerun before merge. The
|
||||
reviewer routing job uses the built-in `GITHUB_TOKEN` to request reviewers with
|
||||
`Contents: read` and `Pull requests: write`. A separate base-owned cleanup job
|
||||
isolates the merge-authority permissions (`Contents: write` and `Pull
|
||||
requests: write`), revalidates the exact event base/head, and uses the built-in
|
||||
token only to disable an existing request owned by `github-actions[bot]` or one
|
||||
whose title or merge metadata requests that GitHub skip workflows; it never
|
||||
enables auto-merge. The job then mints a current-repository installation token
|
||||
for the dedicated Reviewer Router GitHub App, proves its emitted slug matches
|
||||
the reviewed `REVIEWER_ROUTER_APP_SLUG`, replaces every non-App request, and
|
||||
enables native auto-merge with fixed metadata. This
|
||||
identity boundary is required because GitHub suppresses
|
||||
most workflow events created by the built-in token; using it for auto-merge would
|
||||
silently skip the merge commit's protected-main CI and baseline-cache
|
||||
producer. Token minting or takeover fails closed without falling back to
|
||||
`GITHUB_TOKEN`: the unsafe request is cleared before credentials are read, and
|
||||
the required `Test` context live-verifies the exact `main-merge-writers` update
|
||||
rule. GitHub's read APIs may omit `parameters` for the strict
|
||||
`update_allows_fetch_and_merge: false` value, so the gate accepts only that
|
||||
exact omission or a one-field `parameters` object containing explicit boolean
|
||||
`false`; every other present shape or value fails closed. The gate then binds
|
||||
the same ruleset node through GraphQL and requires its non-null
|
||||
`updateAllowsFetchAndMerge` value to be exactly `false`. It also requires its
|
||||
own built-in token to report
|
||||
`current_user_can_bypass: never`. The minted App separately requires
|
||||
`pull_requests_only` on that writer rule and `never` on every other active main
|
||||
ruleset before it can enable or reconcile auto-merge. The read-only `Test`
|
||||
token may receive a repository projection with both merge-default properties
|
||||
omitted; it accepts only that complete omission or exact `MERGE_MESSAGE` plus
|
||||
`PR_TITLE`/`BLANK`, while partial or malformed projections fail closed. The
|
||||
minted App's `Contents: write` token must observe the exact reviewed defaults
|
||||
before either mutation path proceeds. GitHub hides the complete
|
||||
`bypass_actors` list from low-privilege callers, so the rollout audit must still
|
||||
keep the writer list at exactly the Reviewer App plus `PeterGuy326` (ID
|
||||
`47820304`). The required check finally accepts a null or exact App-owned
|
||||
request after a short takeover grace period. Null is safe from the suppressed
|
||||
event path because the built-in Actions identity cannot update `main`; other
|
||||
permitted identities produce either a main push or the trusted closed-PR
|
||||
repair. Drafts skip the identity step, while `ready_for_review`, `edited`,
|
||||
`auto_merge_enabled`, and `auto_merge_disabled` explicitly start fresh admission
|
||||
for readiness, title, and merge-request changes. Router does not react to
|
||||
`auto_merge_disabled`, so a
|
||||
human can deliberately leave the PR manual-only for break-glass handling.
|
||||
Reviewer routing remains available. The protected-main push that deploys the
|
||||
workflow automatically migrates every open, ready non-App request and repairs
|
||||
unsafe App metadata; it disables workflow-skipping requests for correction. A
|
||||
manual `workflow_dispatch` from `main` is the idempotent retry path.
|
||||
Reconciliation never enables an originally null request. The reviewer router
|
||||
is orchestration, not a quality context, and
|
||||
must not be added to the ruleset.
|
||||
|
||||
GitHub may omit `pull_request_target` for security-sensitive head branch names,
|
||||
including names that look like commit SHAs. Those PRs cannot use Router App
|
||||
takeover or the closed-event repair: rename the branch for the supported path,
|
||||
or use the designated break-glass identity with a safe final message so main
|
||||
push CI remains the exact-SHA producer.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
@@ -187,8 +242,11 @@ flag 迁移。
|
||||
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 历史字段。
|
||||
Interface lifecycle 的 exact rename、command move、flag extraction、availability
|
||||
hardening 或完整 product retirement 规范化到当前历史副本,不会维护第二份 allowlist,
|
||||
也不会放宽其他 Schema 历史字段。产品退役必须在共享 command ledger 中精确列出
|
||||
merge-base / stable 的全部 historical command 与 Schema tool 并集;candidate 不能用同一
|
||||
PR 新增记录并删除产品。
|
||||
|
||||
For a release-seal branch that archives rendered fragments:
|
||||
|
||||
@@ -213,7 +271,75 @@ the same dedicated cache profile path because GitHub includes that path in the
|
||||
cache version; the runtime-facing candidate and baseline filenames remain
|
||||
separate. Near-miss reuse is forbidden — the caches carry no prefix restore
|
||||
keys, because a neighbouring commit's profile would compare the candidate
|
||||
against the wrong baseline. Supporting and (when
|
||||
against the wrong baseline. CI concurrency is keyed by PR number plus exact
|
||||
event base/head SHA. Duplicate runs for that exact revision may cancel each
|
||||
other, but a later revision cannot kill an earlier cold-cache producer. Main
|
||||
runs use the pushed SHA, so a newer main push cannot cancel a predecessor's
|
||||
producer.
|
||||
|
||||
Every supported main advancement path has an exact-SHA producer. The required
|
||||
`Test` context rejects GitHub workflow-skip directives in PR and auto-merge
|
||||
metadata, reruns when that metadata is enabled, disabled, or edited, and
|
||||
verifies the live App/writer-ruleset identity contract. Reviewer Router
|
||||
additionally binds auto-merge to the exact head OID and writes a fixed safe
|
||||
merge headline/body. The sole break-glass publisher must retain a safe final
|
||||
message; the release-controlled Formula-only path
|
||||
is the sole supported use of `[skip ci]`. A full source push
|
||||
saves the assembled profile after the aggregate gate passes. A trusted
|
||||
documentation or release-seal push independently verifies that the complete
|
||||
`before...after` diff contains only the reviewed metadata allowlist, restores
|
||||
only the exact `before` cache, recomputes the full profile if the chain is
|
||||
cold, and makes that helper a dependency of the required `Coverage` context.
|
||||
Release-generated Formula commits intentionally retain `[skip ci]`; after
|
||||
their nine synthetic contexts are sealed, an independent release-governance
|
||||
job creates an acknowledgement and emits a `coverage-baseline-promote`
|
||||
repository dispatch. The default-branch promotion
|
||||
workflow revalidates the exact single-parent Formula identity, successful
|
||||
parent and target contexts, and main containment before it promotes the exact
|
||||
parent cache or performs the same full fallback. Every target-main producer
|
||||
follows its save with a lookup-only restore and requires
|
||||
`cache-hit=true` for the exact key; this turns the cache action's otherwise
|
||||
warning-only upload failure or prefix match into a hard failure. Formula
|
||||
promotion additionally updates one release-created `Coverage Baseline Cache`
|
||||
check. A separate confirmation job waits for that exact check-run ID while npm
|
||||
and mirrors remain dependent only on the immutable publication job; cache
|
||||
failure therefore makes the final delivery gate red without creating a
|
||||
partially published release. Once Formula sealing exposes its SHA, a later
|
||||
publication verification failure cannot suppress that confirmation job.
|
||||
|
||||
A separate base-owned `pull_request_target: closed` safety net covers the final
|
||||
merged SHA even if a human or integration changes the merge message after PR
|
||||
checks finish. Skip directives alone do not suppress `pull_request_target`,
|
||||
subject to GitHub's separate security-sensitive branch-name restriction above.
|
||||
That job executes no PR code and only dispatches after binding the exact
|
||||
closed-event PR number and stable head SHA to merged-PR facts
|
||||
(`merged_at`, `base.ref`, and `merge_commit_sha`) and proving `main`
|
||||
containment. It does not compare the later REST `base.sha`, which follows the
|
||||
live base branch after merge. Because GitHub makes default-branch caches
|
||||
read-only to `pull_request_target`, the dispatcher first waits up to one minute
|
||||
for a run from the exact protected
|
||||
`.github/workflows/ci.yml` workflow and exits when that normal producer exists.
|
||||
A successful main CI hard-verifies the exact key itself. A completed
|
||||
non-success run starts a separate base-owned `workflow_run` dispatcher, which
|
||||
binds the exact CI workflow ID/path, run ID/attempt, conclusion, upstream
|
||||
repository, `main` branch, and head SHA. That trigger is also cache-read-only,
|
||||
so either trusted dispatcher uses `repository_dispatch`; its producer
|
||||
revalidates the merged-PR or failed-CI identity, checks out the contained SHA,
|
||||
and produces/verifies the exact full cache.
|
||||
An hourly schedule and a main-only manual dispatch repair the event-time main
|
||||
SHA after a direct break-glass push or cache eviction. The dispatch exception
|
||||
is intentional: unlike an ordinary event created by `GITHUB_TOKEN`, GitHub
|
||||
allows `repository_dispatch` to start another workflow. A legacy built-in-token
|
||||
merge can suppress the closed event too, which is why the required `Test`
|
||||
identity gate and dedicated Reviewer Router App are still mandatory.
|
||||
|
||||
A cold miss can still occur during a producer race or after cache eviction,
|
||||
but it remains fail-safe: the PR recomputes the authoritative baseline with a
|
||||
30-minute job budget and saves a PR-scoped copy for same-PR reruns. It is no
|
||||
longer possible for a supported main-advance path to omit its producer
|
||||
silently. That PR-scoped fallback save remains a best-effort acceleration and
|
||||
does not replace the normal push, metadata, Formula, and merged-PR repair
|
||||
producers. Supporting and (when
|
||||
platform-selected) native profiles are generated before the aggregate
|
||||
`Coverage` context evaluates them. The
|
||||
aggregate and native gates require 100% coverage for changed executable Go
|
||||
@@ -230,6 +356,11 @@ helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking c
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
首次增加旧 merge-base 不认识的 migration `kind` 必须拆成三个 PR:先只落 parser、
|
||||
lifecycle、CLI/Schema adapters、wrapper capability 与 hostile tests;再在 surface 不变时
|
||||
新增 `pending`;最后才由产品 PR 消费为 `consumed`。CLI 与 Schema wrappers 会在旧
|
||||
authority 首次看到 `product_retirement` 时提前拒绝,不能把机制与审批记录塞进同一 PR。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
@@ -240,7 +371,8 @@ shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
前拒绝冲突输入。`product_retirement` 机制 PR 不含任何 pending 退役记录,因此自身不授权
|
||||
删除产品。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
@@ -265,4 +397,11 @@ unproducible required context.
|
||||
|
||||
The branch ruleset also requires one approval after the latest push. Enable
|
||||
repository auto-merge and automatic head-branch deletion; keep the base-owned
|
||||
reviewer router outside the required-context list.
|
||||
reviewer router outside the required-context list. Install its dedicated
|
||||
GitHub App only on this repository with `Contents: read and write` and `Pull
|
||||
requests: read and write`; do not grant Actions, Workflows, or Administration.
|
||||
Give it pull-request-only bypass on `main-merge-writers` and no bypass on any
|
||||
other ruleset. Store the App client ID and lowercase slug in repository
|
||||
variables `REVIEWER_ROUTER_APP_CLIENT_ID` and `REVIEWER_ROUTER_APP_SLUG`, and
|
||||
its private key in repository secret `REVIEWER_ROUTER_APP_PRIVATE_KEY`. Do not
|
||||
reuse release, Homebrew, or personal tokens for this boundary.
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
# CLI Help / Schema 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口。迁移必须保持原 flag 的 requiredness:optional 只能迁到 optional,required 只能迁到 required。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
本文定义两种受控 flag 迁移:
|
||||
|
||||
同一套 base-owned lifecycle 也治理两类跨命令迁移:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;或把旧命令中的一个可选 flag 拆成新的专用命令。跨命令迁移只允许清单精确声明的 `command_became_hidden` / `flag_became_hidden` 及其 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
1. `flag_rename`:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 设为唯一可见入口;rename 必须保持原 flag 的 requiredness,optional 只能迁到 optional,required 只能迁到 required。
|
||||
2. `requiredness_change`:同一个公开 flag 从 optional 精确提升为 required;flag 的名称、类型、作用域、可见性、shorthand、`no_opt` 与 alias 关系必须保持不变。
|
||||
|
||||
两种原语都只放行清单精确登记的变化,不是通用 breaking-change 豁免,也不得在同一 command/flag 上叠加以绕过 rename 的 requiredness 保持规则。
|
||||
|
||||
同一套 base-owned lifecycle 也治理跨命令迁移和完整产品退役:旧命令保留执行能力但从 Help / Schema 导航隐藏,并迁到新的公开命令路径;把旧命令中的一个可选 flag 拆成新的专用命令;或在独立审批后整体删除一个 product root 及其全部 CLI / Schema surface。清单只允许每种 `kind` 精确声明的 finding 与 Schema 投影,不是通用 command-path breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
@@ -33,7 +38,7 @@ Smoke fixture,不参与迁移审批。
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入 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。
|
||||
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 ledger 时,base 已拥有 flag comparator;bootstrap 仍只执行 base-owned 普通比较,不向旧 helper 传入新的 command ledger,也不会授予任何迁移豁免。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此新 parser、lifecycle、launcher 与 hostile tests 必须先由真人评审,合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
@@ -44,16 +49,58 @@ scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
scripts/policy/interface-migrations/approved-command-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。历史未声明 `kind` 的记录按 `flag_rename` 解释;新增同名 requiredness 迁移必须显式写 `kind: requiredness_change` 和单一 `flag` before/after。清单中的 `pending` 记录只记录已评审计划,并授权其精确列出的后续产品迁移;候选与 merge-base 仍必须精确匹配 `before`,不能授权同一个提交中的接口变化,也不能作为其他命令或参数的通配豁免。
|
||||
|
||||
首次引入一个旧 merge-base 不认识的新 `kind` 时,必须使用三个独立 PR:机制 PR 只合入 parser、lifecycle、CLI/Schema adapter、wrapper capability 与 hostile tests,清单保持不含该 kind;待实现成为新的 merge-base authority 后,治理审批 PR 只新增 `pending` 且产品 surface 不变;最后产品 PR 才能删除或迁移 surface 并把记录改成 `consumed`。两个 authoritative wrapper 都会在旧 authority 首次收到 `product_retirement` 时提前拒绝并提示三阶段流程;旧严格 parser 的 unknown-kind 拒绝仍是兜底。
|
||||
|
||||
## 跨命令迁移原语
|
||||
|
||||
`approved-command-migrations-v1.json` 只接受两种 `kind`:
|
||||
`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 的承接关系 |
|
||||
| `schema_availability_hardening` | 精确命令保持 runnable,并按记录变 hidden 或保持 compatibility-visible | 同一 tool 只能从记录的 `availability.before` 变为 `availability.after`,其他字段仍由普通兼容检查约束 |
|
||||
| `product_retirement` | 一个精确 product root 及其全部后代从已声明的 before surface 整体变为 absent | 删除同名 Schema product;`commands` 与 `schema.tools` 必须分别完整覆盖 merge-base / stable 的历史并集 |
|
||||
|
||||
### 完整产品退役
|
||||
|
||||
`product_retirement` 的 `legacy.command` 必须是 `dws <product_id>` 这一层的精确 product root;不接受子路径、alias、prefix 或 wildcard。root 的 before 必须是 runnable,并精确声明当时的 `hidden` 值,因此既能治理公开 root,也能治理真实存在的 hidden runnable root;after 只能是 absent。该 kind 不声明 replacement、legacy flag、参数映射或 availability。
|
||||
|
||||
记录必须包含两个已排序、无重复的完整集合:
|
||||
|
||||
- `commands`:merge-base 与 stable Interface Snapshot 中该 root 本身和全部后代的 canonical path 并集;root 必须包含在内。任一历史快照出现未登记后代,或登记了两份历史都不存在的命令,都会失败。
|
||||
- `schema.tools`:merge-base 与 stable normalized Schema 中同名 product 的全部 canonical tool ID 并集;每个 ID 必须以 `<product_id>.` 开头。stable 可以没有该产品或只含较早子集,但两份历史都必须受同一记录约束。
|
||||
|
||||
治理审批 PR 中,candidate 的完整命令对象集合和 Schema product 必须与 merge-base 深度相等;只删一个 child、改 flag / alias / safety、改 tool contract,或在同一 PR 删除产品都会被视为 self-approval。产品 PR 消费 base-owned pending 时,只接受 CLI subtree 和 Schema product 同时整体 absent。普通 Interface Compare 会把整棵树的删除折叠为 root `command_removed`,但 lifecycle 会在过滤该 finding 前独立核对每一个历史 command,因此折叠不会扩大授权。
|
||||
|
||||
下面只是结构示例,不代表已审批产品;首次落地该 kind 的机制 PR 也不得加入这条记录:
|
||||
|
||||
```json
|
||||
{
|
||||
"kind": "product_retirement",
|
||||
"legacy": {
|
||||
"command": "dws edu-app",
|
||||
"before": {"present": true, "runnable": true, "hidden": true},
|
||||
"after": {"present": false}
|
||||
},
|
||||
"commands": [
|
||||
"dws edu-app",
|
||||
"dws edu-app task",
|
||||
"dws edu-app task list"
|
||||
],
|
||||
"schema": {
|
||||
"product_id": "edu-app",
|
||||
"tools": [
|
||||
"edu-app.query_all_task",
|
||||
"edu-app.query_publish_task"
|
||||
]
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Retire the reviewed education application product surface."
|
||||
}
|
||||
```
|
||||
|
||||
`command_move` 只能隐藏没有子命令的 legacy leaf,且 legacy 与 replacement
|
||||
不得互为祖先路径;整棵命令树的迁移需要单独设计逐叶治理,不能复用这一原语。
|
||||
@@ -68,6 +115,17 @@ optional bool legacy flag,不能隐藏仍由 Cobra hard-required 的参数。
|
||||
`replacement_constant.value` 与 legacy `no_opt` 都必须是 `true`;negative flag、默认即
|
||||
`true` 或固定 `false` 的语义不在本轮证明范围,必须另行设计,不能借本清单放行。
|
||||
|
||||
如果 `command_move` 的参数 `from` 在更早 stable 中仍使用另一历史名称,Schema adapter
|
||||
只能把同一 legacy command 上、已经由 base-owned lifecycle 返回且
|
||||
`state=consumed` 的 flag rename 回执作为前驱边。例如
|
||||
`group → conversation-id` 与 `conversation-id → open-topic-id` 可以组合,但不能把
|
||||
candidate 自增的 pending 记录、其他命令的同名参数、参数概念词典或 CLI alias 当作证据。
|
||||
首次消费 pending command 回执时,merge-base 的 normalized Schema 必须真实发布中间参数,
|
||||
并逐跳验证参数签名和 constraints;command 回执合入为 consumed 后,中间 Schema 已从 main
|
||||
消失,此时保留的两份 consumed 回执可继续对 stable 做受限重放,直到 stable 也达到 after
|
||||
并让回执转为惰性记录或由独立 PR 清理。两种阶段都拒绝残留 predecessor/intermediate、字段漂移、环、分叉、
|
||||
target 碰撞或 primary path/tool identity 不唯一;positionals 不在该组合授权面内。
|
||||
|
||||
`replacement_constant` 不是清单自报即可成立的例外。after 阶段的 Interface Snapshot
|
||||
必须从 replacement 命令的同一份框架运行时声明中捕获完全一致的 property/value,缺失、
|
||||
值不符或额外常量都会使 lifecycle 落入 partial。对于 #1054,`dws chat topic create`
|
||||
@@ -84,24 +142,24 @@ bool 常量证据属于 bootstrap;一旦任一历史快照已记录该
|
||||
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 或前缀扩大授权。
|
||||
`command_move` 与 `flag_extraction` 都要求旧 argv 继续可执行。删除旧命令、删除旧 flag、把 legacy 改成 non-runnable、改变未登记的历史参数、改变 interface / safety,或只完成部分 before → after 转换都会 fail closed。命令别名会先规范到 reference 的 canonical path,但清单本身仍只能记录精确 canonical 命令,不能用 alias 或前缀扩大授权。`product_retirement` 是唯一允许整体删除命令的 kind,并受上一节的完整集合证明约束。
|
||||
|
||||
跨命令清单复用下文同一套 `pending → consumed → cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
跨命令清单复用下文同一套 `pending → consumed → inert/cleanup` 生命周期。治理 PR 只能新增 `pending` 且产品 surface 必须仍是 before;后续产品 PR 才能一次性切到 after 并改为 `consumed`。candidate 新增的 pending 记录不能批准自己的改动。
|
||||
|
||||
当前首批 pending 记录覆盖 `chat topic` 收口:`chat group create --thread` 拆到 `chat topic create`,以及 `chat message list-topic-replies` / `forward-topic` 迁到对应的 `chat topic` 命令。前一条完整登记 `name` / `type` / `users` 的同名承接,以及 `thread` → `convThreadEnabled=true` 的常量承接。产品 PR 消费这些记录时只能把三条 `state` 改为 `consumed`,不得改写其 before、after、Schema mapping、constant 或 reason。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical flag)` 为唯一精确键,并经历以下生命周期:
|
||||
rename 以 `(kind, command, legacy flag, canonical flag)` 为唯一精确键;requiredness change 以 `(kind, command, flag)` 为唯一精确键。二者经历同一生命周期:
|
||||
|
||||
| 阶段 | PR 可以做什么 | 必须满足的快照状态 |
|
||||
|---|---|---|
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;该记录不改变 stable 的判断 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | legacy 仍存在但由 visible 变 hidden,且声明 `alias_of`;canonical 的 requiredness 与 legacy 迁移前完全一致 |
|
||||
| 1. 治理审批 | 新增 `state: pending` 的精确记录;不得在同一个 PR 修改产品 surface | candidate 和 merge-base 都与记录中的 `before` 完全一致;产品退役还要求 command subtree 与 Schema product 深度相等 |
|
||||
| 2. 产品迁移 | merge-base 已拥有 `pending` 后,按记录一次性切到精确 `after`,并把记录改为 `state: consumed` | rename 的 legacy 仍存在但由 visible 变 hidden;requiredness change 只提升同名 flag;产品退役则要求完整 CLI subtree 与 Schema product 同时 absent |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
| 4. 惰性保留或清理 | 当 merge-base 和 stable 都已经是 `after`,该记录不再提供任何授权;后续 PR 可以原样保留或删除 | 两份参考快照均精确匹配 `after`;保留时仍必须是不可改写的 `consumed`,接口偏离 `after` 继续失败 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。stable 发布只会让已经追平的 `consumed` 回执变成无授权效果的审计记录,不会在没有代码变更时让后续业务 PR 失去合规性;清理仍可作为独立的账本压缩动作,但不再是下一个 PR 的强制前置条件。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
@@ -146,6 +204,27 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
同名 flag requiredness 迁移的清单结构如下;示例不代表已经审批:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"kind": "requiredness_change",
|
||||
"command": "dws report entry submit",
|
||||
"flag": {
|
||||
"name": "to-user-ids",
|
||||
"before": {"present": true, "type": "string", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "Reject report submissions that have no visible recipient."
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
@@ -166,10 +245,13 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下三类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. required legacy 被新增的 required canonical 替代时产生的 `required_flag_added`;如果 canonical 在 before 阶段只是 hidden 占位符,则允许它在转为公开拼写时继承 legacy 的 requiredness。已有的 visible canonical 不允许借 rename 改变 requiredness。
|
||||
3. `requiredness_change` 中同名 flag 从 optional 提升为 required 时产生的 `flag_became_required`。
|
||||
|
||||
跨命令记录另有各自的窄授权。`product_retirement` 只有在 lifecycle 已证明 current 整棵 subtree absent、且每个 merge-base / stable 历史命令都被完整登记后,才会移除该 product root 的 `command_removed`(以及同一 root 的 alias removal)finding;它不会按 prefix 过滤任意 child finding,也不会授权其他产品。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
@@ -177,6 +259,7 @@ replacement 必须保留 source 已发布的 dry-run 能力:历史 `dry_run`
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- requiredness change 同时发生的 rename、隐藏、类型、scope、shorthand、`no_opt` 或 alias 漂移;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
@@ -204,6 +287,14 @@ adapter 先构造经过上述验证的历史 contract 副本,再调用原 Sche
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
`requiredness_change` 的 Schema adapter 只把历史同名 parameter 的 `required` 与
|
||||
`cli_required` 提升到 candidate 的 `true` 值,并要求 candidate 两者都为 `true`。parameter
|
||||
不存在、tool/path 不匹配时不制造 Schema surface;type、property、interface type、default、
|
||||
format、enum、`required_when`、constraints、positionals 与 safety 等全部字段仍交给原 checker,
|
||||
任何不相干漂移继续阻塞。
|
||||
|
||||
`product_retirement` 的 Schema adapter 读取同一条 command ledger 记录,不维护第二份 allowlist。checker 必须同时收到 merge-base 与 stable 两份 normalized Schema:pending 的 `schema.tools` 等于两份历史的精确并集,candidate-added pending 还要求 current product 与 merge-base 深度相等;消费时 current 不得再发布该 product。adapter 只从当前被检查的历史副本删除该完整 product,再交给普通兼容检查,其他 product 不变。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Every runtime command the `dws` CLI exposes when loaded with the **pre** environment configuration.
|
||||
|
||||
- **Products**: 13
|
||||
- **Total commands**: 160
|
||||
- **Total commands**: 163
|
||||
- **Generated from**: `internal/plugin` command descriptors — the same code path the CLI uses at runtime.
|
||||
|
||||
> Auto-generated. Update plugin descriptors in `internal/plugin/`, not this file.
|
||||
@@ -33,7 +33,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws aitable` — AI Tables](#dws-aitable) · 41 commands
|
||||
- [`dws attendance` — Attendance](#dws-attendance) · 4 commands
|
||||
- [`dws calendar` — Calendar](#dws-calendar) · 14 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 23 commands
|
||||
- [`dws chat` — Group Chat / IM](#dws-chat) · 26 commands
|
||||
- [`dws contact` — Contact Directory](#dws-contact) · 6 commands
|
||||
- [`dws devdoc` — Open Platform Docs](#dws-devdoc) · 2 commands
|
||||
- [`dws ding` — DING Messages](#dws-ding) · 2 commands
|
||||
@@ -134,12 +134,15 @@ _Calendar events, participants, meeting rooms, and busy-status queries._
|
||||
|
||||
_Group chats, conversations, messages, and robot/webhook integrations._
|
||||
|
||||
**23 commands**
|
||||
**26 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws chat bot search` | Search robots (bots) created by the current user by keyword. | When the agent needs to resolve one of its own bots by name to a robot code before sending bot messages. |
|
||||
| `dws chat conversation-info` | Retrieve basic metadata for a conversation (single chat or group chat) by conversation ID. | When the agent needs context about a conversation (name, type, member count) before operating on it. |
|
||||
| `dws chat emotion favorite` | Add a media ID to the current user's personal favorite emotions. | When the agent needs to save an available mediaId as a reusable personal emotion, optionally preserving source message context. |
|
||||
| `dws chat emotion list` | List the current user's personal favorite emotions. | When the agent needs to inspect available personal emotions or resolve an emotionId/mediaId before sending. |
|
||||
| `dws chat emotion send` | Send a personal favorite emotion to a group or direct chat as the authenticated user. | When the agent needs to send a known personal emotion mediaId to exactly one group, userId, or openDingTalkId target. |
|
||||
| `dws chat group create` | Create a new internal group chat with a set of initial members. | When the agent needs to spin up a dedicated group for a new project, incident, or discussion thread. |
|
||||
| `dws chat group members` | List members of a group chat; can also be used against the current user to enumerate their groups' members. | When the agent needs the roster of a group before mentioning, removing, or auditing members. |
|
||||
| `dws chat group members add` | Add one or more users to an existing group chat. | When the agent expands a group to include additional participants. |
|
||||
|
||||
@@ -0,0 +1,454 @@
|
||||
# AI 表格数据源指令使用指南
|
||||
|
||||
## 概述
|
||||
|
||||
dws 新增了 7 个 AI 表格数据源同步管理指令,用于将外部数据源(一期支持审批数据)接入 AI 表格,实现数据的自动同步。
|
||||
|
||||
所有指令均通过 `dws aitable +datasource-*` 前缀调用,操作对象是 AI 表格中的"数据源表"——一种由数据源同步创建的特殊数据表。
|
||||
|
||||
## 指令速览
|
||||
|
||||
| 指令 | 用途 | 读写 | 风险 |
|
||||
|------|------|------|------|
|
||||
| `+datasource-list-sources` | 列出数据源类型可用的来源信息(OA 返回 result/processCode、sourceType、sourceUrl) | 读 | low |
|
||||
| `+datasource-get-fields` | 获取数据源来源的可同步字段结构 | 读 | low |
|
||||
| `+datasource-create` | 创建数据源表并触发首次同步 | 写 | medium |
|
||||
| `+datasource-update` | 更新已有数据源表的同步配置 | 写 | medium |
|
||||
| `+datasource-sync` | 手动触发一次同步 | 写 | medium |
|
||||
| `+datasource-sync-status` | 查询同步任务状态 | 读 | low |
|
||||
| `+datasource-get-config` | 查看数据源表配置 | 读 | low |
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. **登录认证**:执行 `dws auth login` 确保已登录
|
||||
2. **获取 Base ID**:通过 `dws aitable +base-list` 或 `dws aitable +base-search --query "关键词"` 获取目标 AI 表格的 Base ID
|
||||
|
||||
---
|
||||
|
||||
## 1. 列出数据源可用来源
|
||||
|
||||
```
|
||||
dws aitable +datasource-list-sources [flags]
|
||||
```
|
||||
|
||||
列出指定数据源类型可用的来源信息。OA 审批类型返回当前 Base 可用的审批数据源条目(`sources` 数组,当前通常为单条),用于构造 `+datasource-create` / `+datasource-update` / `+datasource-get-fields` 的 `--source-config`。OA 场景下每条 source 的 `result` 字段是 JSON 字符串,需解析后得到 `approvals` 数组,再从中提取目标模板的 `processCode`、`name`、`iconUrl`、`url`。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 列出审批数据源来源,获取 result(JSON,解析后得到 approvals[].processCode)
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回 `sources` 数组,每个条目包含:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `result` | OA 审批场景为 JSON 字符串,解析后得到 `approvals` 数组;每个 approval 含 `processCode`、`name`、`iconUrl`、`url` |
|
||||
| `sourceType` | 数据源类型编号(OA 对应内部枚举值 2) |
|
||||
| `sourceUrl` | 数据源访问链接,可选 |
|
||||
|
||||
`result` 本身不是 `processCode`,需要解析出 `approvals` 数组,再取目标模板的 `processCode`、`name`、`iconUrl`、`url` 原样填入 `--source-config`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 获取数据源可同步字段
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-fields [flags]
|
||||
```
|
||||
|
||||
获取指定数据源来源(如某个审批模板)的可同步字段列表,包括字段 ID、字段名称、字段类型和是否主键等信息。用于创建数据源前选择需要同步的字段。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串,结构同 `+datasource-create` 的 `--source-config` |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 获取某审批模板的可同步字段
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回可同步字段列表,每个字段包含字段 ID、名称、类型和是否主键。字段 ID 可用于 `+datasource-create` / `+datasource-update` 的 `--field-ids` 参数。
|
||||
|
||||
---
|
||||
|
||||
## 3. 创建数据源表
|
||||
|
||||
```
|
||||
dws aitable +datasource-create [flags]
|
||||
```
|
||||
|
||||
为指定 AI 表格创建数据源同步配置,自动创建一张数据源表并触发首次全量同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID(通过 `+base-list` / `+base-search` 获取) |
|
||||
| `--datasource-type` | string | 是 | 数据源类型,目前支持审批(OA) |
|
||||
| `--source-config` | string | 是 | 源配置 JSON 字符串(格式见下方) |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步,默认 false;无论是否传入,CLI 都会把该字段下发给下游 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在 `--auto=true` 时生效,格式见下方 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时同步全部字段 |
|
||||
|
||||
### source-config 格式(审批类)
|
||||
|
||||
审批数据源的 `--source-config` 是一个 JSON 对象字符串,包含以下字段:
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `processCode` | string | 是 | 审批模板编码,对应 `+datasource-list-sources` 返回的 `result` |
|
||||
| `name` | string | 是 | 数据源展示名称,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `iconUrl` | string | 是 | OA 审批图标 URL,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `url` | string | 是 | OA 审批跳转链接,须从 `+datasource-list-sources` 结果原样透传 |
|
||||
| `dataType` | string | 是 | 数据时间范围类型:`time_range` / `start_time` / `recent_time` |
|
||||
| `recentDays` | string | 当 dataType=recent_time 时必填 | 近 N 天:`7d` / `30d` / `1y` |
|
||||
| `startDate` | string | 当 dataType=time_range 或 start_time 时必填 | 起始日期,格式 `yyyy-MM-dd` |
|
||||
| `endDate` | string | 当 dataType=time_range 时必填 | 结束日期,格式 `yyyy-MM-dd` |
|
||||
| `keepRemovedFields` | bool | 否 | 是否保留已删除字段,默认 false |
|
||||
|
||||
> 注:`splitParentTableField`、`enableDataSyncOaDetailList` 等字段为下游内部字段,无需传入,下游自动处理。
|
||||
|
||||
按 `dataType` 选择对应的时间参数组合:
|
||||
|
||||
| dataType | 需要的时间字段 | 说明 |
|
||||
|----------|----------------|------|
|
||||
| `recent_time` | `recentDays` | 同步近 N 天数据(7d/30d/1y) |
|
||||
| `start_time` | `startDate` | 同步从某日期至今的数据 |
|
||||
| `time_range` | `startDate` + `endDate` | 同步指定日期范围内的数据 |
|
||||
|
||||
### auto-sync-setting 格式
|
||||
|
||||
`--auto-sync-setting` 仅在 `--auto=true` 时生效,用于指定自动同步频率。不传时使用下游默认策略。
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `syncType` | string | 是 | `hourly`(按小时间隔)/ `scheduled`(定时触发) |
|
||||
| `hourlyInterval` | int | hourly 时必填 | 正整数,小时间隔 |
|
||||
| `scheduleType` | string | scheduled 时必填 | `daily` / `weekly` / `monthly` |
|
||||
| `timeValue` | string | scheduled 时必填 | 触发时间,格式 `HH:mm` |
|
||||
| `selectedMonthDays` | int[] | monthly 时必填 | 每月几号触发,1-31 |
|
||||
| `selectedWeekdays` | int[] | weekly 时必填 | 每周哪几天触发,1=周一…7=周日 |
|
||||
| `skipNonWorkingDay` | bool | 否 | 是否跳过非工作日,默认 false |
|
||||
|
||||
示例:`{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 基本创建——同步近 30 天审批数据
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 指定日期范围创建并开启自动同步
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"time_range","startDate":"2025-01-01","endDate":"2025-12-31","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--auto
|
||||
|
||||
# 指定同步字段(仅同步部分字段,field-ids 可通过 +datasource-get-fields 获取)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}' \
|
||||
--field-ids fldAAA,fldBBB,fldCCC
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
创建成功后返回新建数据源表 ID 和同步任务 ID,后续操作需要用到这两个 ID。
|
||||
|
||||
---
|
||||
|
||||
## 4. 更新数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-update [flags]
|
||||
```
|
||||
|
||||
更新已有数据源表的同步配置,支持更新源配置、自动同步开关和同步字段选择。更新后会自动触发一次同步。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 已存在的数据源表 ID(由 `+datasource-create` 返回) |
|
||||
| `--source-config` | string | 否 | 新的源配置 JSON 字符串,不传时保持原有配置。结构同 `+datasource-create` |
|
||||
| `--auto` | bool | 否 | 是否开启自动同步;仅显式设置时下发给下游,省略时保持原有自动同步开关不变 |
|
||||
| `--auto-sync-setting` | string | 否 | 自动同步频率配置 JSON 字符串,仅在显式设置 `--auto=true` 时生效;省略时保持原频率配置 |
|
||||
| `--field-ids` | stringSlice | 否 | 需要同步的字段 ID 列表,不传时保持现有字段配置 |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 更换审批模板并调整时间范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 更新同步字段范围
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--field-ids fldAAA,fldDDD
|
||||
```
|
||||
|
||||
> 注意:`--table-id` 指向的是数据源表(由 `+datasource-create` 创建),不是普通数据表。
|
||||
|
||||
---
|
||||
|
||||
## 5. 触发手动同步
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync [flags]
|
||||
```
|
||||
|
||||
对已有数据源表触发一次手动同步。单次最多 5 张表,每张表独立提交,部分失败不影响其他表。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-ids` | stringSlice | 是 | 待触发同步的数据源表 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 同步单张表
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1
|
||||
|
||||
# 批量同步多张表(逗号分隔,最多 5 个)
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL1,TBL2,TBL3
|
||||
```
|
||||
|
||||
### 返回值
|
||||
|
||||
返回每个表的同步任务 ID,可通过 `+datasource-sync-status` 查询最终结果。
|
||||
|
||||
---
|
||||
|
||||
## 6. 查询同步状态
|
||||
|
||||
```
|
||||
dws aitable +datasource-sync-status [flags]
|
||||
```
|
||||
|
||||
按任务 ID 查询数据源表的同步任务状态。与 `+datasource-sync` / `+datasource-create` / `+datasource-update` 配对使用——这些指令触发同步后返回任务 ID,本指令通过任务 ID 查询最终结果。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
| `--task-ids` | stringSlice | 是 | 待查询的同步任务 ID 列表(1-5 个) |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
# 按任务 ID 查询(批量,最多 5 个)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK1,TASK2
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 获取数据源配置
|
||||
|
||||
```
|
||||
dws aitable +datasource-get-config [flags]
|
||||
```
|
||||
|
||||
获取指定数据源表的同步配置信息,包括源配置、同步模式、自动同步开关和同步状态。
|
||||
|
||||
### 参数
|
||||
|
||||
| 参数 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `--base-id` | string | 是 | 目标 Base ID |
|
||||
| `--table-id` | string | 是 | 数据源表 ID |
|
||||
|
||||
### 示例
|
||||
|
||||
```bash
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 典型工作流
|
||||
|
||||
### 场景一:从零接入审批数据
|
||||
|
||||
```bash
|
||||
# 0. 获取 Base ID
|
||||
dws aitable +base-search --query "我的项目表"
|
||||
|
||||
# 1. 列出可用审批数据源来源,解析 result JSON 获取 approvals[].processCode
|
||||
dws aitable +datasource-list-sources \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA
|
||||
# → 返回 sources[0].result 为 JSON 字符串,解析后取 approvals[0].processCode=PROC-XXXX
|
||||
|
||||
# 2. 查看可同步字段(可选,用于指定 field-ids)
|
||||
dws aitable +datasource-get-fields \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 3. 创建数据源表(创建后自动触发首次同步)
|
||||
dws aitable +datasource-create \
|
||||
--base-id BASE123 \
|
||||
--datasource-type OA \
|
||||
--source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
# → 返回 tableId=TBL456, taskId=TASK001
|
||||
|
||||
# 4. 查询首次同步是否完成
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
|
||||
# 5. 确认配置
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
```
|
||||
|
||||
### 场景二:更换审批模板后重新同步
|
||||
|
||||
```bash
|
||||
# 1. 更新源配置(更新后自动触发一次同步)
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--source-config '{"processCode":"PROC-NEW","name":"新审批模板","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'
|
||||
|
||||
# 2. 查询同步状态(更新后会返回新的 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK002
|
||||
```
|
||||
|
||||
### 场景三:手动触发日常同步
|
||||
|
||||
```bash
|
||||
# 仅触发同步,不修改配置
|
||||
dws aitable +datasource-sync \
|
||||
--base-id BASE123 \
|
||||
--table-ids TBL456
|
||||
|
||||
# 查询结果(sync 会返回 taskId)
|
||||
dws aitable +datasource-sync-status \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--task-ids TASK001
|
||||
```
|
||||
|
||||
### 场景四:开启自动同步后确认
|
||||
|
||||
```bash
|
||||
# 1. 更新配置,开启自动同步
|
||||
dws aitable +datasource-update \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456 \
|
||||
--auto
|
||||
|
||||
# 2. 确认配置已更新
|
||||
dws aitable +datasource-get-config \
|
||||
--base-id BASE123 \
|
||||
--table-id TBL456
|
||||
# → 返回中应显示 auto=true
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 通用选项
|
||||
|
||||
以下全局选项可在所有指令中使用:
|
||||
|
||||
| 选项 | 说明 |
|
||||
|------|------|
|
||||
| `-f, --format` | 输出格式:json(默认)/ table / raw / pretty / ndjson / csv |
|
||||
| `--jq` | jq 表达式过滤输出(如 `.tableId` 或 `.status`) |
|
||||
| `--fields` | 筛选输出字段(逗号分隔) |
|
||||
| `--dry-run` | 预览操作内容,不实际执行 |
|
||||
| `--profile` | 指定组织或账号 |
|
||||
| `--timeout` | HTTP 请求超时时间(秒,默认 30) |
|
||||
| `--debug` | 显示调试日志 |
|
||||
| `-v, --verbose` | 显示详细日志 |
|
||||
|
||||
### 输出过滤示例
|
||||
|
||||
```bash
|
||||
# 只取 tableId
|
||||
dws aitable +datasource-create ... --jq '.tableId'
|
||||
|
||||
# 只取同步状态
|
||||
dws aitable +datasource-sync-status ... --jq '.status'
|
||||
|
||||
# table 格式查看
|
||||
dws aitable +datasource-get-config ... -f table
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **推荐流程**:先 `+datasource-list-sources` 解析 `result` JSON 获取 `approvals[].processCode`,再 `+datasource-get-fields` 查看可同步字段,最后 `+datasource-create` 创建数据源表。
|
||||
|
||||
2. **数据源表 vs 普通数据表**:`+datasource-create` 创建的是"数据源表",它由数据源同步驱动数据写入。`+datasource-update` 和 `+datasource-sync` 仅适用于数据源表,不可对普通数据表使用。
|
||||
|
||||
3. **datasource-type 透传**:CLI 层不对 `--datasource-type` 做枚举校验,目前一期仅支持 `OA`(审批)。后续支持其他类型时由服务端控制,CLI 无需修改。
|
||||
|
||||
4. **source-config 格式**:`--source-config` 必须是合法 JSON 字符串。审批数据源需要原样透传 `processCode`(从 `+datasource-list-sources` 返回的 `result` JSON 中解析 `approvals[]` 提取)、`name`、`iconUrl`、`url`,设置 `dataType`(时间范围类型),并按 `dataType` 提供对应的时间参数(`recentDays` / `startDate` / `endDate`)。
|
||||
|
||||
5. **同步限制**:`+datasource-sync` 单次最多 5 张表;`+datasource-sync-status` 单次最多查询 5 个任务 ID。
|
||||
|
||||
6. **创建即同步**:`+datasource-create` 和 `+datasource-update` 在操作完成后会自动触发一次同步,无需额外调用 `+datasource-sync`。
|
||||
|
||||
7. **自动同步**:`--auto` 开启后,数据源表会按 `--auto-sync-setting` 指定的频率自动定期同步;未指定频率时使用服务端默认策略。关闭 `--auto` 后仅能通过 `+datasource-sync` 手动触发。
|
||||
+10
-5
@@ -23,7 +23,7 @@
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew 直交付 DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、命令兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
为缩短封板前后的关键路径,`publish` 的只读版本规划会与平台治理检查并行,seal 仍严格等待二者成功;plan 在 candidate annotated tag 上验证过的 contract 和 stable/beta baseline 会绑定进 seal,并由 seal 后的 tag authority 检查复用。Code Admission 状态与 immutable-releases 治理仍会在 seal 后再次读取,避免 preflight 与发布之间的状态变化被忽略。随后三类只读门禁(release automation、CLI 与 Schema 兼容性、multi-profile E2E)与 GoReleaser 构建并行;Node/archive 等仅供后处理使用的工具也延后到构建完成后安装。并行和已验证结果复用只改变调度,不降低发布门禁:任何一条验证失败都会阻止 GitHub Release、npm、镜像和 Homebrew 发布,delivery proof 也要求三条验证 job 全部成功。
|
||||
|
||||
OSS 镜像默认不参与发布 DAG,适用于尚未创建 Bucket 的仓库。云端封板会把当时的仓库变量 `ENABLE_OSS_MIRROR=true` 记录为不可变 tag 元数据 `OSS-Mirror: enabled`,否则记录为 `deferred`;后续发布和撤回只读取该 sealed policy,不读取变量的当前值。`enabled` 继续对缺失凭据、无效 Bucket、上传、pointer 和撤回失败保持 fail-closed;`deferred` 明确跳过不存在的渠道。为避免补发后撤回遗漏,deferred 版本暂不接受 `repair_oss_version`,启用 OSS 只影响后续新 tag,直到补齐可审计的不可变 repair 证明。
|
||||
|
||||
@@ -102,7 +102,7 @@ fragments,然后停止。审阅生成内容并通过唯一的 release-seal PR
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
预检包含测试、策略检查、旧正式版 CLI 与 Schema 双基线兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -140,14 +140,19 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
`.changes/<unique-name>.md` 中增加一个独立 fragment;格式和允许的分类见
|
||||
[`.changes/README.md`](../.changes/README.md)。预发封板时
|
||||
`scripts/release/prepare-changelog.sh prerelease <version>` 会稳定排序并汇总所有未归档
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。因此并发 PR 不会争用
|
||||
`CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与归档移动,供审计复核。
|
||||
fragment,写入唯一版本章节后移动到 `.changes/released/<version>/`。如果 beta 发布后又有
|
||||
带 fragment 的 PR 合入,而维护者决定直接发布 stable,
|
||||
`scripts/release/prepare-changelog.sh stable <version> --from-beta <tag>` 会保留 beta 晋级摘要
|
||||
模板,并把这些 post-beta fragments 写到明确的 `Changes since <beta>` 边界之后,再移动到
|
||||
`.changes/released/<stable-version>/`。没有 active fragment 时,stable 仍只生成原有晋级摘要
|
||||
模板。因此并发 PR 不会争用 `CHANGELOG.md`;唯一的 release-seal PR 同时提交生成的章节与
|
||||
归档移动,供审计复核。
|
||||
|
||||
## CI/CD 保证
|
||||
|
||||
- 只接受 `vX.Y.Z-beta.N` 和 `vX.Y.Z`,且新版本必须高于上一正式版。这里的“上一正式版”必须同时具备公开非草稿 GitHub Release 和同 tag/commit 的成功 Release workflow;只有 tag、没有交付成功的孤儿版本会阻断后续发布,要求走机器核验恢复补齐。云端 tag 会固定 `Release-Run`、requester、commit 和版本分配指纹,交付验证按该精确 run/attempt 及完整 job graph 取证,不接受任意 `workflow_dispatch`。历史版本若曾通过专用 recovery workflow 完成交付,只能使用仓库内 `delivered-stable-recoveries.json` 中精确到 tag、commit、run、workflow SHA 与 attempt 的 reviewed 证据。
|
||||
- tag 必须由云端 seal job 创建为 annotated tag;封板提交必须已通过 PR 合入并包含在远端 `main` 历史中。流水线允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整命令树;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- 日常 CI 和发布前都会对比“最新已交付正式版”的完整 CLI 与 Schema 契约;若长时间预检期间该 baseline 发生变化,会针对新的 baseline 重新比较。
|
||||
- GoReleaser 只构建;Darwin 重签、checksums 重算和 npm 安装验证通过后,才统一上传 GitHub Release 的最终产物。
|
||||
- 六个平台归档会逐个解包并核验二进制内嵌版本;公开资产集合、checksums 集合和 npm tarball integrity 都必须精确一致。npm tarball 固定由 npm `10.9.2` 打包,避免重跑时因 runner 自带 npm 漂移产生不同字节。
|
||||
- stable 发布到 npm `latest`;prerelease 发布到 npm `beta`。启用 `ENABLE_OSS_MIRROR=true` 后,stable 同步 OSS `latest.txt` 和共享安装脚本,prerelease 只同步 OSS `beta.txt`,不会覆盖稳定入口。
|
||||
|
||||
@@ -274,6 +274,7 @@ Definition(仅声明;不可编译)
|
||||
| 层 | 含义 | 今日落点 |
|
||||
|---|---|---|
|
||||
| **声明(declare)** | `corecmd.Spec` / `LeafSpec` / `ContractDecl` **数据字段**(声明证据;交付见下) | `Flags`/`Constraints`/`Risk`/`ConstParams`/`Contract`;类型真身在 `corecmd/contract`(DTO:`SafetySpec`/`ParamDecl`/`ProductDecl`/`ContractFinalPayload`;**无** Cobra store) |
|
||||
| **非叶声明(group declare)** | owning Cobra 命令上的完整 `corecmd.GroupPolicy`;不是 leaf `Spec` 字段 | `Mode` / `Positionals` / `Recovery` 经 `corecmd.ApplyGroupPolicy` 一次编译为 Cobra 行为与私有框架元数据 |
|
||||
| **框架转换** | 类型转换并注册(**禁止** JSON 注解桥) | `embedContractDecl` → `corecmd/contractfinal.RegisterRuntimeContractFinal`(annotate + store;全部调用方直调,`corecmd.New` 内部注册) |
|
||||
| **注解 seam** | Cobra `dws.schema.*` 写入 | `internal/corecmd/runtimeannotate.AnnotateRuntime*`(框架侧;`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/runtimeannotate` 垫片包已删,一律直引 corecmd) |
|
||||
| **Schema 透传** / 交付 | 组装读取注册表,原样投影为 `ToolSpec`;`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`(`ResolveMeta` 自同一组装投影);go:embed 仅限 reviewed 输入(MCP meta / `param_concepts` 等;reviewed `schema_command_registry/` 已退役,identity 由 collector 收集),映射排除走 Go ledger(`schema_parameter_mapping_ledger.go`),不得 embed Catalog | `internal/cli` 根(交付边界);ContractFinal store 在 `corecmd/contractfinal`(`cli` 根经 `runtime_schema_seam.go` 包内别名访问;`cli/contractfinal` 垫片包已删) |
|
||||
@@ -310,7 +311,25 @@ Definition(仅声明;不可编译)
|
||||
3. 写副作用:新 Leaf 声明完整 `SafetySpec`(框架 `ConfirmSafety` + Schema Final);未迁移旧路径显式标注 `runtime_gate`;二者皆无则不合格;
|
||||
4. Schema `ToolSpec` 全字段组均落在 §5.0.4 表中某一权威格,禁止无主字段。
|
||||
|
||||
#### 5.0.2a 三档声明路径(Tier1 / Tier2 / Tier3)
|
||||
#### 5.0.2a 非叶命令契约(`corecmd.GroupPolicy`)
|
||||
|
||||
`corecmd.Spec` / `LeafSpec` 继续只定义叶命令。每个拥有子命令的 owning Cobra 命令必须在构造处通过 `corecmd.ApplyGroupPolicy` 声明一份完整 `GroupPolicy`:
|
||||
|
||||
| 轴 | 允许值 | 语义 |
|
||||
|---|---|---|
|
||||
| `Mode` | `navigation_only` / `hybrid` | 仅导航并展示帮助,或同时保留本命令业务执行 |
|
||||
| `Positionals` | `reject` / `allow` | 未匹配 token 进入命令恢复,或由本命令业务位置参数消费 |
|
||||
| `Recovery` | `sibling` / `deep` / `disabled` | 只建议直接子命令、显式允许后代路径恢复,或完全关闭恢复 |
|
||||
|
||||
硬规则:
|
||||
|
||||
1. 三个字段必须同时声明;全零值只表示 leaf,不能应用到命令。`navigation_only` 必须 `Positionals=reject`;`Positionals=allow` 必须 `Recovery=disabled`,避免业务 argv 与命令恢复争抢同一 token。
|
||||
2. `ApplyGroupPolicy` 是唯一编译入口:navigation 安装统一 help/错误 handler;hybrid 保留 owning `RunE`,仅在声明拒绝 positionals 且开启恢复时包裹 unknown-command 分支。恢复统一投影为有界 `CommandResolution`(最多 3 个建议 + 当前 parent `--help`);只有 `Recovery=deep` 才可建议完整后代路径。
|
||||
3. `GroupPolicy` **不推导** `TraverseChildren`。该 Cobra 字段会改变父级 local flag 是否向子命令传播,必须由原 owning command 显式保留,不能因迁移到 typo guidance 而扩大参数表面。
|
||||
4. 最终装配树门禁检查「有 children 必须有 GroupPolicy、leaf 不得残留 GroupPolicy、navigation/hybrid handler 与声明结构一致」。门禁不执行任意 `Args` 函数;`ApplyGroupPolicy` 对 `cobra.NoArgs` / `cobra.ArbitraryArgs` 的编译由 corecmd 单测覆盖。
|
||||
5. 命令树合并时,两侧非空 group 都必须先声明 policy;冲突声明、group 与 runnable/parse-bearing leaf 合并、或带 children 的未声明节点均 fail closed。纯 metadata 空壳可采用 typed source policy,不能借此吞掉 flags、hooks 或执行体。
|
||||
|
||||
#### 5.0.2b 三档叶声明路径(Tier1 / Tier2 / Tier3)
|
||||
|
||||
当前生产允许的三档路径(同一 `ContractFinal` 语义;不是互相否定):
|
||||
|
||||
|
||||
@@ -1,310 +0,0 @@
|
||||
# Attendance Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`69bda96e49c7a478729b5f9232677fd9055e5d7d`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:Lark CLI 1.0.87
|
||||
> 范围:Attendance Shortcut only;不改 DWS 产品 Skill 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
- Attendance 共审核 35 个源码 Shortcut;8 个具备 Agent 公开条件,27 个保持 unavailable。为守住已发布 CLI 的 argv/Help 兼容,其中 11 个历史可见入口继续以 compatibility-visible 形式可发现,但仍从 Agent public Catalog 排除、保持 legacy 输出且不发布 Result/Pagination;其余 16 个保持 hidden。公开数量按「严格响应合同 + 稳定身份 + 安全真实 fixture」的发布门计算,不把空数组或仅退出码 0 计为通过。
|
||||
- 这 11 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;它们的 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 MCP 字段名由 Execute 的显式 adapter 负责,不能在未经过版本化迁移时重定向已发布 Schema property。
|
||||
- `+check-result` 已覆盖 Lark CLI 当前唯一 Attendance 用户任务 `attendance user_tasks query`;DWS inventory 还包含打卡流水、审批、班次、规则、设置、假期和个人视图等更宽能力。排班查询入口虽然保留历史 CLI 兼容,但因 `DS-ATTENDANCE-008` 当前保持 Agent-unavailable。
|
||||
- 已确认 8 组下游需求:补卡规则详情返回空结果、报表合同不足、打卡结果分页缺少服务端确定终止证据、缺少安全可回收的管理员/写操作 fixture、6 个读场景缺少请求绑定字段或 nonempty/zero 双态 fixture、班次详情不回显稳定 ID、个人设置缺少逐场景权限发现与安全 fixture,以及排班查询对合法非空/空请求均返回 `exit 0 + literal null`。
|
||||
- 审批模板的同类型多模板问题已在上游修复:以 `processCode` 作为资源身份,`approveType` 只做请求绑定,并要求 `submitUrl` 非空。班次详情与个人设置仍有下游合同/权限前置,不能以请求 echo 或部分场景成功伪造整体可用。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 建议 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-ATTENDANCE-001` | P1 | business-service defect / contract insufficient | 搜索后读取补卡规则详情 | unavailable | Attendance Wukong 规则服务 | `+get-adjustment-rule` |
|
||||
| `DS-ATTENDANCE-002` | P1 | business-service defect / contract insufficient | 发现报表列并查询考勤/假期报表 | unavailable | Attendance 报表服务 / MCP adapter | `+list-report-columns`, `+query-report-data`, `+query-report-leave` |
|
||||
| `DS-ATTENDANCE-003` | P2 | contract insufficient | 可靠翻完打卡结果 | partial | Attendance 打卡查询服务 | `+check-result` 完整分页 |
|
||||
| `DS-ATTENDANCE-004` | P1 | tenant-or-fixture / permission | 验证考勤组、全局设置、余额和写操作 | blocked / unavailable | Attendance 产品测试基础设施 / 权限 Owner | 14 个读写 Shortcut |
|
||||
| `DS-ATTENDANCE-005` | P1 | response contract / tenant-or-fixture | 可验证地读取摘要、假期、签到和个人考勤 | blocked / unavailable | Attendance 查询服务 / 产品测试基础设施 | 6 个读 Shortcut |
|
||||
| `DS-ATTENDANCE-006` | P1 | response contract | 用搜索得到的班次 ID 精确读取同一班次详情 | unavailable | Attendance Wukong 班次服务 | `+get-class` |
|
||||
| `DS-ATTENDANCE-007` | P1 | capability / permission fixture | 可发现地读取全部个人设置场景 | blocked / unavailable | Attendance 设置服务 / 权限 Owner / 测试基础设施 | `+get-self-setting` |
|
||||
| `DS-ATTENDANCE-008` | P1 | response contract | 可验证地读取员工排班 | unavailable | Attendance Wukong 排班服务 / MCP adapter | `+get-schedule` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 批量查询员工打卡结果 | `attendance +check-result` | `attendance user_tasks query` | covered;框架分页 token 由当前页保守派生 | contract insufficient | 声明 `Pagination(kind=cursor,cursor_parameter=offset)`;续页只放 `meta.pagination`,业务 `data` 仅含 `count/records` |
|
||||
| 搜索并读取班次 | `+search-class` → `+get-class` | 无同级入口 | partial | response contract | 只公开搜索;详情因不回显请求 classId 而 unavailable |
|
||||
| 搜索并读取补卡规则 | `+search-adjustment-rule` → `+get-adjustment-rule` | 无同级入口 | partial | business-service defect | 只公开搜索;详情 unavailable |
|
||||
| 发现字段并查询考勤报表 | `+list-report-columns` → `+query-report-data` | 无同级入口 | unavailable | contract insufficient | 两个入口均不进入 Agent Catalog;历史 `+query-report-data` 仅保留 CLI 兼容可见性 |
|
||||
| 查询假期报表 | `+query-report-leave` | 无同级入口 | unavailable | business-service defect | hidden/unavailable |
|
||||
| 搜索并读取考勤组 | `+search-group` → `+get-group` | 无同级入口 | blocked | tenant-or-fixture | 无已知非空安全 fixture;历史 `+search-group` 仅保留 CLI 兼容可见性,二者都不进入 Agent Catalog |
|
||||
| 查询企业全局设置和假期余额 | `+get-global-setting`, `+get-leave-balance` | 无同级入口 | blocked | permission / fixture | hidden/unavailable |
|
||||
| 查询个人设置 | `+get-self-setting` | 无同级入口 | partial | capability / permission fixture | 前五个场景已验证;全部场景发布前保持 Agent-unavailable,仅保留历史 CLI 兼容可见性 |
|
||||
| 查询员工排班 | `+get-schedule` | 无同级入口 | unavailable | response contract | 合法非空与保证零命中请求均收到 `exit 0 + literal null`;旧 CLI 兼容可见,但不进入 Agent Catalog |
|
||||
| 修改排班、班次、考勤组、假期和打卡结果 | 9 个写 Shortcut | 无同级入口 | unsafe to verify | tenant-or-fixture / contract insufficient | hidden/unavailable,不以 dry-run 记通过 |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-ATTENDANCE-001` — 让搜索得到的补卡规则可被稳定读取
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先按名称浏览补卡规则,再用结果中的稳定主键读取完整规则。
|
||||
- canonical Shortcut:`attendance +search-adjustment-rule`、`attendance +get-adjustment-rule`。
|
||||
- atomic/raw route:`attendance adjustment search`、`attendance adjustment get`。
|
||||
- Exact Shortcut 与 atomic/raw 均使用搜索返回的同一候选主键;搜索明确成功且非空,详情调用明确 `success=true`,但 `result=null`。
|
||||
- 已排除上游空数组投影、整数解析和候选字段遗漏:多个可作为候选的数值字段均未得到非空详情;加班规则的相邻搜索→详情闭环正常。
|
||||
- 置信度:高。仍需下游确认“搜索 ID 与详情 ID 不同”还是详情服务未返回对象。
|
||||
- 安全证据句柄:`ATT-DETAIL-NULL-01`;仓库不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 明确 `get_adjustment_rule` 列表项中哪个字段是 `get_adjustment_rule_detail.adjustmentId` 的稳定主键;名称和类型必须在 Schema 中一致。
|
||||
- 对存在且有权限的规则返回 `success=true` 和非空对象 `result`,对象必须回显同一稳定规则 ID。
|
||||
- 对不存在、已删除、无权限、租户未开通分别返回稳定的 typed error;不得以 `success=true + result=null` 表示任一失败。
|
||||
- 如详情接口不受支持,提供可发现的 capability/feature 状态,或在搜索结果中返回足以完成详情任务的完整对象并声明字段稳定性。
|
||||
- 改动应 additive/versioned;旧字段保留兼容期,禁止静默改变现有 ID 的语义。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 创建或选择隔离规则,atomic search 非空并取得稳定 ID。
|
||||
2. atomic detail 和 exact `+get-adjustment-rule` 均返回同一 ID 的非空对象。
|
||||
3. 不存在 ID、无权限和已删除 ID 分别返回非零 typed error。
|
||||
4. 上游恢复公开后,搜索→详情 E2E 通过且仓库/远端无测试残留。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
`+get-adjustment-rule` 保持 Agent-unavailable 并从公开 Catalog 排除;旧 CLI 入口仅为 argv/Help 兼容继续可见,`+search-adjustment-rule` 不再承诺详情入口可用。
|
||||
|
||||
### `DS-ATTENDANCE-002` — 提供可发现、可验证的考勤报表合同
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- Golden Route:列出企业可查询报表列 → 选择稳定列 ID → 查询一批员工的列值;另一路径按假期类型查询时长报表。
|
||||
- canonical Shortcut:`+list-report-columns`、`+query-report-data`、`+query-report-leave`。
|
||||
- atomic/raw operations:`get_report_columns`、`get_report_columns_value`、`get_leave_time_by_leave_names`。
|
||||
- 观察:列发现与假期报表调用均退出码 0 且 payload 为 JSON `null`;使用未经验证的列 ID 查询列值仅得到显式空数组,不能证明列 ID 有效或查询正确。
|
||||
- 已排除上游投影丢失:原子调用本身即返回 `null`;Shortcut 现已拒绝把 `null` 当作合法空集合。
|
||||
- 置信度:高。权限/租户功能可能是触发条件,但接口没有返回可区分的状态。
|
||||
- 安全证据句柄:`ATT-REPORT-NULL-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_report_columns`:成功时必须返回显式列数组;每项含稳定 `columnId`、显示名、值类型、单位、支持的日期/人员范围和是否需要管理员权限。
|
||||
- 合法无列必须是 `success=true + result=[]`;未开通、无权限和服务异常必须是不同 typed error,不得返回裸 `null`。
|
||||
- `get_report_columns_value`:返回值必须绑定请求的用户集合、列 ID 和时间范围;未知列返回 `COLUMN_NOT_FOUND`,不能静默得到空数组。
|
||||
- `get_leave_time_by_leave_names`:返回显式数组并包含稳定用户身份、假期类型标识、单位和数值;合法零记录为显式空数组。
|
||||
- 列值和假期报表若分页,必须提供 page/cursor、hasMore 和终止证据;批量用户存在部分失败时返回逐项 ledger 与整体 partial status。
|
||||
- 提供安全 capability discovery:租户是否开通、调用身份所需权限、最大用户数、最大列数、最大时间跨度。
|
||||
|
||||
#### C. 验收标准
|
||||
|
||||
1. 管理员测试租户中列发现有已知非空和明确空租户两组 E2E。
|
||||
2. 使用发现的同一 `columnId` 执行 atomic 与 exact Shortcut,返回与请求用户/区间绑定的非空值。
|
||||
3. 未知列、无权限、未开通和超范围分别产生稳定非零错误。
|
||||
4. 假期报表至少覆盖已知非空、合法空和未知假期类型。
|
||||
5. 分页/partial 分支和远端零残留通过。
|
||||
|
||||
#### D. 临时处置
|
||||
|
||||
三个报表 Shortcut 均保持 Agent-unavailable;其中历史 `+query-report-data` 只保留 CLI 兼容可见性。不得用 `null`、请求 echo 或未验证列产生的空数组标记 PASS。
|
||||
|
||||
### `DS-ATTENDANCE-003` — 为打卡结果提供确定的分页终止证据
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+check-result` 已真实返回非空打卡结果并覆盖 Lark 任务;当前接口只接受 `offset/limit`,响应缺少稳定总量、hasMore 或 nextOffset。
|
||||
- DWS 只能在返回条数小于 limit 时证明结束;满页时保守输出 `meta.pagination.endpoint_exhausted=false` 和 `next_token=offset+count`,不能声明全量完成。`complete/nextOffset/limit` 仅保留在 legacy 兼容输出,unified 业务 `data` 不冒充分页协议。
|
||||
- 安全证据句柄:`ATT-CHECK-PAGE-01`。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 响应增加 `hasMore` 与 `nextOffset`,或 `totalCount`;这些字段必须与同一快照/排序一致。
|
||||
- 固定稳定排序键和同 offset 重放语义;说明并发新增/修改是否可能造成重复或漏项。
|
||||
- 空页且 `hasMore=true` 必须仍给出前进 token/offset;重复或倒退 offset 为协议错误。
|
||||
- 声明最大 limit、最大时间跨度和超过上限的 typed validation error。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
- 验收覆盖多页、最后一页、零记录、满页但仍有下一页、重复 token/offset 和并发变更。
|
||||
- 下游完成前,DWS 使用框架 `PaginationSpec` 和 `meta.pagination`表达保守续页;`cursor_parameter=offset` 表示调用者将 `next_token` 作为下一次 `--offset`,不表示下游已提供服务端 opaque cursor。满页始终不会被当作已完整。
|
||||
|
||||
### `DS-ATTENDANCE-004` — 建立可回收的 Attendance 管理员与写操作测试资源
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 受影响读取:`+search-group`、`+get-group`、`+get-group-filtered`、`+get-global-setting`、`+get-leave-balance`。
|
||||
- 受影响写入:`+import-schedule`、`+create-class`、`+update-class`、`+update-group-members`、`+create-group`、`+update-group`、`+update-leave-type`、`+save-leave-balance`、`+boss-check`。
|
||||
- 当前安全身份没有已知非空考勤组 fixture;全局设置被权限拒绝;余额读取没有可验证结果。写操作会影响真实员工规则,且部分资源缺删除/恢复能力,因此未执行生产数据写入。
|
||||
- 这不是对业务接口必然有 bug 的结论,而是可测试性和权限前置不足。
|
||||
- 安全证据句柄:`ATT-FIXTURE-GAP-01`。
|
||||
|
||||
#### B. 需要的测试基础设施与合同
|
||||
|
||||
- 提供隔离租户或专用测试组织,包含:管理员测试身份、两个无业务含义测试成员、一个可删除考勤组、一个可删除班次、一个可恢复假期类型、可控排班与打卡结果。
|
||||
- 只授予完成相应接口所需的最小 scopes;提供 capability discovery,区分权限不足、功能未开通和资源不存在。
|
||||
- 写接口返回稳定资源 ID、逐项结果、幂等/commit-unknown 语义;所有更新支持精确读回。
|
||||
- 为不可删除的企业设置提供 snapshot/restore 或专用 reset API;余额和 BOSS 改签必须能恢复原值。
|
||||
- Fixture 有 TTL、Owner 和自动清理告警;日志只保留受控 evidence handle,不输出业务内容或身份值。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 考勤组搜索有已知非空和保证零命中;详情绑定同一 ID。
|
||||
2. create→get→update→restore/delete 覆盖班次、考勤组与排班。
|
||||
3. 成员、余额和打卡结果写入均有 before/after 精确读回并恢复原值。
|
||||
4. 未确认时远程写调用为 0;任一 partial/commit-unknown 非零退出。
|
||||
5. 测试结束远端和本地均零残留。
|
||||
|
||||
在完整 fixture 到位前,相关 Shortcut 保持 hidden/unavailable。
|
||||
|
||||
### `DS-ATTENDANCE-005` — 为 6 个读场景提供请求绑定与双态 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-summary`:真实响应只含统计项,不回显请求 user、period 或 statsType,上游无法证明返回属于哪个请求。
|
||||
- `+list-leave-types`:当前安全租户只有已知非空列表,而命令无筛选参数;不能用越界分页或错误请求伪造合法空结果。
|
||||
- `+get-leave-records`、`+get-checkin-record`:当前只取得合法空结果,缺少已知非空流水 fixture,无法排除响应投影或请求绑定错误。
|
||||
- `+my-attendance`、`+this-month`:上游已严格验证当前用户 profile 与每条打卡 ID,但当前期间仅有合法空数组,缺少同一身份下的已知非空 fixture。
|
||||
- 安全证据句柄:`ATT-READ-FIXTURE-GAP-01`;不保存 raw body、用户 ID 或打卡时间。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 摘要响应回显稳定 userId、统计周期起止和 statsType,或返回可校验的请求摘要;任一字段不一致必须 typed failure。
|
||||
- 提供隔离的「无假期类型」测试租户,以显式 `success=true + result=[]` 证明 `+list-leave-types` 的合法空语义。
|
||||
- 提供可创建、读取并清理的假期变更流水、签到流水和打卡流水;每项都必须包含稳定 ID、请求用户和时间范围回显。
|
||||
- 为 nonempty 与 guaranteed-zero 提供独立 fixture;未知用户、无权限、未开通和合法空集合必须可区分,不得都返回裸 `null` 或无标识空数组。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 每个集合叶子都用 exact Shortcut 和 owning atomic/raw 在同一参数下各证明一次已知非空和一次合法保证零命中。
|
||||
2. 非空项的稳定 ID、用户和时间绑定在两层结果中一致;空结果仍有显式业务 success 和正确集合容器。
|
||||
3. malformed/null/success=false/错身份/超范围均非零失败,且不会继续调用后续考勤接口。
|
||||
|
||||
在上述证据完整前,6 个 Shortcut 均保持 Agent-unavailable,并仅为历史 argv/Help 保留 CLI 兼容可见性;已实现的严格校验不等于已获得发布证据。
|
||||
|
||||
### `DS-ATTENDANCE-006` — 让班次详情回显可验证的稳定身份
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:先用 `+search-class` 浏览班次并取得稳定 `classId`,再用同一 ID 读取班次详情。
|
||||
- canonical Shortcut:`+search-class`、`+get-class`;atomic/raw route:`attendance class search`、`attendance class get`。
|
||||
- 在 clean discovery HEAD 上,搜索 exact/raw 均返回同一组非空正整数 `classId`;使用其中真实 ID 调用 raw detail,服务端返回 `success=true` 和非空 `shiftVO`,但对象没有 `id` 或 `classId`。
|
||||
- 上游不能把请求 ID 注入响应来伪造 readback,也不能仅凭“非空详情”证明详情属于请求资源。因此 `+get-class` 保持 unavailable。
|
||||
- 安全证据句柄:`ATT-CLASS-ID-ECHO-GAP-01`;不保存 raw body、资源 ID 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- `get_class_detail` 成功对象必须回显与请求精确一致的稳定 `id`/`classId`,类型与 `get_class_list` 列表身份字段一致。
|
||||
- 存在、已删除、不存在、无权限和租户未开通必须返回可区分的 typed terminal 状态;不得以非空但无身份对象表示可验证成功。
|
||||
- 明确班次 ID 的租户作用域、生命周期和搜索→详情一致性;如详情存在版本号,也应返回稳定版本字段以支持更新前读回。
|
||||
- 改动需 additive/versioned;现有详情业务字段保持兼容。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. exact/raw 搜索得到同一非空 `classId`,同 ID detail 均返回身份精确匹配的非空对象。
|
||||
2. 不存在、已删除和无权限分别非零 typed failure,不能成为 `success=true + result=null` 或无身份对象。
|
||||
3. 上游 `+get-class` 的 missing/false/null/malformed/wrong-ID 回归与真实 E2E 全部通过。
|
||||
|
||||
下游补齐稳定 ID 回显前,`+get-class` 保持 hidden/unavailable;`+search-class` 仍可独立公开。
|
||||
|
||||
### `DS-ATTENDANCE-007` — 提供个人设置逐场景 capability 与权限安全 fixture
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- `+get-self-setting` 公开参数包含 6 个场景。clean discovery HEAD 上,前 5 个场景的 exact/raw 均能精确绑定请求 userId、场景字段和已观测类型;`bossAttendStatNotify` 在两层均返回稳定业务错误 `NO_PERMISSION`。
|
||||
- 当前接口没有 capability discovery 告知调用身份可读哪些场景,也没有可安全授权的隔离 fixture。只验证 5/6 不能宣称整个公开枚举可用。
|
||||
- 这不是把权限错误误判为业务空结果;exact/raw 均非零退出。上游保留严格 user/scene/type 校验,但发布面整体降级。
|
||||
- 安全证据句柄:`ATT-SELF-SETTING-PERMISSION-GAP-01`。
|
||||
|
||||
#### B. 需要下游提供的合同与 fixture
|
||||
|
||||
- 提供 capability discovery,返回当前调用身份逐场景的 readable/forbidden/unsupported 状态、所需最小 scope/角色和租户功能开通状态。
|
||||
- 为 6 个场景提供字段名、类型、可空性和版本化语义;成功必须回显请求 userId,并明确返回对应场景字段。
|
||||
- 提供隔离测试身份或可撤销的临时最小权限授权 fixture,使 6 个场景均能完成 exact/raw 同场景验证;测试后权限必须回收。
|
||||
- 无权限、场景不支持、用户不存在和设置未配置必须返回不同 typed error;不得统一为 `null`、空对象或无标识空成功。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. capability discovery 与 6 个场景实际调用一致,不遗漏权限前置。
|
||||
2. 每个场景 exact/raw 的 userId、场景字段、类型和对象内容一致;`null`、错类型、错用户均非零。
|
||||
3. bogus user、invalid scene、无权限和未开通均返回可区分非零错误。
|
||||
4. 权限 fixture 全程最小化、可撤销,结束后无授权残留。
|
||||
|
||||
能力发现和安全 fixture 到位前,`+get-self-setting` 保持 Agent-unavailable;旧 CLI 入口仅保留兼容可见性。
|
||||
|
||||
### `DS-ATTENDANCE-008` — 让排班查询返回可判定的成功集合或业务错误
|
||||
|
||||
#### A. 用户任务与现状
|
||||
|
||||
- 用户任务:按员工和日期范围读取逐日排班,用稳定排班 ID 继续执行只读分析或受控的 BOSS 改签。
|
||||
- canonical Shortcut:`attendance +get-schedule`;owning raw route:`attendance-wukong/getScheduleByRange`。
|
||||
- 两次独立 clean HEAD 的真实验证中,已知历史非空区间与保证零命中的未来区间都得到同一结果:owning raw 进程退出 0,但响应为 literal `null`;Exact Shortcut 均以 `response_validation/empty_tool_response` 非零拒绝。
|
||||
- 这既不能证明排班非空,也不能证明合法为空。上游严格校验已避免把 `null` 投影成 `[]`,但在下游提供可判定合同前无法公开该能力。
|
||||
- 安全证据句柄:`ATT-SCHEDULE-NULL-01`;仓库不保存用户、日期、排班 ID、raw body 或 trace。
|
||||
|
||||
#### B. 需要下游提供的合同
|
||||
|
||||
- 成功查询必须返回显式排班数组;每项包含稳定非空排班 ID、请求用户身份、业务日期、班次身份和是否休息等字段。
|
||||
- 合法零结果必须返回 `success=true + result=[]`(或等价的已审核显式集合),不得以裸 `null`、缺字段或空 body 表示。
|
||||
- 无权限、用户不存在、租户未开通、日期范围非法和服务异常必须返回可区分的 typed nonzero error;不得继续用进程退出 0 掩盖业务失败。
|
||||
- 如服务存在分页,必须提供页大小、前进 token/页号、hasMore/total 和明确终止证据;同一请求的 item identity 不得跨页重复。
|
||||
|
||||
#### C. 验收标准与临时处置
|
||||
|
||||
1. 已知非空 fixture 的 raw 与 exact 均返回同一显式数组,稳定 ID 集合、用户和日期绑定一致。
|
||||
2. 保证零命中 fixture 的 raw 与 exact 均返回显式空数组,并有明确终止证据。
|
||||
3. `null`、缺集合、错型 item、重复/空 ID、错用户和越界日期全部非零;错误 reason 可稳定区分。
|
||||
4. 新 clean HEAD 完成 nonempty/zero 双层 E2E,仓库和远端均无测试残留。
|
||||
|
||||
下游修复前,`+get-schedule` 保持 `public=false/unavailable`、legacy 输出且不发布 Result/Pagination;旧 CLI/Help/full Schema 仅为历史兼容继续可发现,不代表 Agent 可用。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 所需下游能力 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|---|
|
||||
| `attendance user_tasks query` 查询打卡结果 | 现有 `query_check_result`;最好补分页终止证据 | yes,分页完整性 partial | Lark 当前没有同级的排班、规则、报表和企业设置任务 | 保留 `+check-result` 为主对齐入口,报告分页边界 |
|
||||
|
||||
无法对齐的不是 DWS 缺入口,而是部分钉钉管理面缺少可验证下游合同或安全 fixture;不能为追求同名率伪造成功。
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 所需下游支持 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|---|
|
||||
| 异常考勤处置队列 | 稳定异常记录 ID、原因、关联审批、处理状态、分页和可恢复更正 | `attendance +exceptions` / `+resolve-exception` | 读写分离;更正确认;写后同 ID 终态读回;可恢复 | P2 |
|
||||
| 跨员工考勤汇总 | 可按组织/成员批量聚合迟到、缺卡、加班、请假并给出统计口径版本 | `attendance +team-summary` | 最小权限、聚合脱敏、口径版本、分页完整性 | P2 |
|
||||
| 规则影响预览 | 更新班次/考勤组/假期前返回受影响成员与日期范围,不提交写入 | `attendance +rule-impact-preview` | 只读、稳定影响计数、无副作用、与最终写请求同参数语义 | P1 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 最终保留公开的 Attendance 集合查询 | 容错 projector 可能把缺字段、错型或坏元素投成 `[]` | 共享严格 success/result/collection 校验;显式空数组才合法;稳定 ID 和请求用户/时间/类型必须绑定 | 单元负向矩阵与最终 clean runtime tree 的 8 个公开入口真实 nonempty/zero、详情或模板 exact/raw 双层复核均完成 |
|
||||
| `+check-record` | 初版误用业务归属日 `workDate` 校验按 `checkDateFrom/checkDateTo` 发起的实际打卡查询,导致跨午夜下班卡被静默丢弃 | 改用 `userCheckTime` 严格绑定请求日期范围;`workDate` 只作为班次归属日原样保留。完整 raw 集合仍必须先通过显式 collection、全量正整数唯一 ID、请求用户和实际打卡时间校验;任何实际时间越界都整次 fail-closed,不再静默过滤 | 最终 live 复核 exact/raw 均为 157 条且完整对象一致;旧轮 `workDate=start-24h`、`userCheckTime` 在范围内的跨午夜 OffDuty 记录明确保留;fresh zero 双层显式空,不由过滤制造 |
|
||||
| `+check-result`, `+list-approve` | 初版把裸日期 `--end` 解析为当天 00:00,可能拒绝结束日白天的结果;旧 end-of-day 语义还会漏最后 999ms | 裸日期结束边界改为本地下一日 00:00 前 1ms;显式 datetime 保持精确值;结束日中午与最后 1ms 可接受,下一日 00:00 非零拒绝 | Execute 回归覆盖结束日中午/最后毫秒/下一日并锁定 reason;最终 live 的 `+check-result` 有真实 end-date item,`+list-approve` end-date 单日 probe exact/raw 一致 |
|
||||
| `+get-approve-template` | 把请求维度 `approveType` 误作集合唯一身份,会拒绝同一类型下多个合法模板 | 改用非空唯一 `processCode` 作为资源身份;`approveType` 仅做请求精确绑定;每项 `submitUrl` 必须非空;允许 TRAVEL/OUT 同类型多项 | missing/wrong/duplicate processCode、wrong approveType、missing/blank submitUrl 负向矩阵;clean HEAD 上 5 个类型 exact/raw 全通过,TRAVEL/OUT 双项集合一致 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | 嵌套 `shiftVO/entityVO` 导致身份投影风险 | 固定审核路径、展开 wrapper、要求正整数且不重复的稳定 ID,严格校验分页矛盾与无前进页 | 坏 item/空 ID/重复 ID/分页矛盾单元回归通过;clean HEAD 上 nonempty/guaranteed-zero 与 raw 对照通过,班次/加班规则另完成实际多页前进与终止 |
|
||||
| `+get-overtime-rule` | 能力存在但缺少请求 ID 与响应对象的强绑定 | 详情对象要求非空且 `id` 与请求精确一致 | missing/false/null/malformed/wrong-ID/valid Execute 级矩阵;clean HEAD 上 exact/raw 同真实搜索 ID 对象一致,raw 对不存在 ID 返回错对象时 exact 非零拒绝 |
|
||||
| `+get-class` | 上游已严格要求 `shiftVO.id`,但真实下游详情不回显任何 ID | 没有注入请求 ID 或放宽校验;按真实合同降级 unavailable | discovery HEAD 上真实搜索→raw detail 非空但 ID 缺失;等待 `DS-ATTENDANCE-006`,修复后再重跑 |
|
||||
| `+get-self-setting` | 仅检查场景 key 存在会让 `null` 伪成功;用户外围空白可造成下传/比较漂移 | 用户输入只归一化一次并以同值下传/比较;场景字段必须非空且符合已观测 object/boolean/integer 类型;因 1/6 场景权限不可验证而整体 unavailable | 5 个 scene exact/raw 对照通过;boss scene exact/raw 均 `NO_PERMISSION`,等待 `DS-ATTENDANCE-007`,不把部分场景成功当整体 PASS |
|
||||
| `+my-attendance`, `+this-month` | 旧的当前用户解析可跳过 malformed row,也可把 success=false 中的 stale result 当身份 | 改为严格 business success/result/唯一用户身份,坏 profile 后考勤 raw 调用为 0;每条打卡要求唯一正整数 ID | 静态/Execute 回归已通过;因当前只有合法空集合而保持 unavailable,不记 live PASS |
|
||||
|
||||
### 6.1 clean-HEAD live 发布门状态
|
||||
|
||||
| 叶子 | clean executable HEAD 双层证据 | 发布状态 |
|
||||
|---|---|---|
|
||||
| `+check-result` | exact/raw known-nonempty 以 20/20/8 三页前进并终止;48 个 ID、用户绑定与逐页对象一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+check-record` | exact/raw 均 157 条且完整对象、稳定 ID 集合一致;跨午夜 `workDate=start-24h`、`userCheckTime` 在范围内的记录已保留;fresh zero 两层均为显式空 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+list-approve` | exact/raw known-nonempty 为 7 条,稳定 ID、用户、类型、日期范围及完整数组一致;合法未来日显式空双层一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-schedule` | 两次独立 clean HEAD 的 known-nonempty 与 guaranteed-zero 均为 raw `exit 0 + literal null`,Exact Shortcut 均非零 `empty_tool_response`;没有把未知结果投影成空数组 | unavailable;等待 `DS-ATTENDANCE-008`,旧 CLI 仅兼容可见 |
|
||||
| `+search-class`, `+search-adjustment-rule`, `+search-overtime-rule` | exact/raw known-nonempty 与随机唯一词 guaranteed-zero 通过;稳定 ID 集合与分页终止一致,班次为 5/5/3 三页,加班规则为 1/1/1 三页 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-overtime-rule` | 使用本轮真实搜索取得的 ID,exact 与 raw 单项对象一致;不存在 ID 的 raw 返回错 ID 对象时 exact 非零拒绝 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-approve-template` | 5 个 approveType 全部 exact/raw 通过,数量 1/1/1/2/2;TRAVEL/OUT 多项 `processCode` 非空唯一且集合一致,类型绑定和提交入口有效 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+get-class` | raw 非空但不回显请求 ID | unavailable;等待下游合同,不以旧调用记 PASS |
|
||||
| `+get-self-setting` | 5 个场景通过,1 个场景 `NO_PERMISSION` | unavailable;等待 capability/权限 fixture,不以部分结果记 PASS |
|
||||
|
||||
pre-rebase discovery 轮次的多页加班规则 raw 验证曾一次返回字面量 `null` 且进程退出 0;该次结果没有计为 PASS,重试后才完成同场景双层分页核对。这是 owning atomic/raw 的下游/renderer 终态合同风险:atomic 不应把 transport/null 失败表示为零退出。Shortcut 自身对 `null` 仍严格非零,不会把它投影为空集合;后续最终轮次未再出现该 transient。
|
||||
|
||||
上述 8 个公开入口均在最终 clean runtime tree 从零重跑,未继承 discovery PASS;最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。`+get-schedule` 的四次 raw `null` 与 Exact 非零结果作为降级证据保留,不计入公开通过数。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含真实用户、组织、租户、profile、规则、排班、考勤组或打卡记录 ID。
|
||||
- 本文不含 trace/request ID、token、签名 URL、邮箱、电话、业务标题正文或真实日程内容。
|
||||
- Raw 响应仅在仓库外临时目录中处理并已删除;本文只保留不可反查的证据句柄和聚合事实。
|
||||
- 进入 Git 前必须扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
@@ -1,198 +0,0 @@
|
||||
# Mail Shortcut 下游业务能力需求规格
|
||||
|
||||
> 日期:2026-08-18
|
||||
> Rebased executable 基线:`3fc3be37c67d14f60273a702a7a6b38f6ba32d4c`;最终 clean PR HEAD 的 live SHA 与发布复核结果记录在 PR 证据中
|
||||
> 对比基线:lark-cli 1.0.87
|
||||
> 范围:Shortcut only;不改 `skills/multi` 或 `skills/mono` 的路由、流程或业务逻辑。仓库 policy 强制的可见 Shortcut 自动生成块单独机械同步。
|
||||
> 发布属性:仓库安全版本;不包含真实邮箱、人员、组织、邮件内容、资源 ID 或请求标识。
|
||||
|
||||
## 1. 执行摘要
|
||||
|
||||
本轮对 18 个 Mail Shortcut 完成严格 success、固定集合路径、稳定 ID、分页完整性和统一 Result 收口。8 个公开只读入口已在相同 runtime tree 逐条完成 Shortcut 与原子层的真实数据双层复核;`+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 因缺少可控 guaranteed-zero fixture 保持 Agent-unavailable,但为守住既有 argv/Help 合同继续以 compatibility-visible 形式留在 CLI;4 个草稿/模板写入口因无法证明清理终态同样不进入公开 Catalog。
|
||||
|
||||
上述 6 个 compatibility-visible 入口在完整 Schema 中保留历史 `availability=available` 与既有 workflow property,仅表示旧调用仍可执行;其 Shortcut 语义状态仍为 `public=false/unavailable`,默认 Shortcut 列表与 Agent public Catalog 均不发布。底层 `folderId`、`size` 等 MCP 字段继续由 Execute 显式适配,不能在未经过版本化迁移时改写已发布 Schema property。
|
||||
|
||||
仍不能诚实对齐的任务集中在草稿/模板清理终态、发送终态、回复/转发草稿语义、批量修改/删除逐项结果、回执、签名、事件监听、模板附件事务和联系人创建身份回执。它们不是再包一层 Shortcut 就能解决,需要下游业务接口或安全测试 fixture 补足可验证合同。
|
||||
|
||||
| ID | 优先级 | 类型 | 用户任务 | 当前状态 | 下游 Owner | 解锁的 Shortcut |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DS-Mail-001` | P0 | contract insufficient | 发信/发送草稿并确认最终投递 | partial | Mail service / adapter | `+send`、`+draft-send` |
|
||||
| `DS-Mail-002` | P0 | missing capability | 回复、回复全部、转发默认保存草稿 | partial | Mail service | `+reply`、`+reply-all`、`+forward` |
|
||||
| `DS-Mail-003` | P0 | contract insufficient | 批量修改、移动、软删除邮件 | partial | Mail service / adapter | `+message-modify`、`+message-trash` |
|
||||
| `DS-Mail-004` | P1 | missing capability | 处理已读回执与邮箱签名 | unavailable | Mail service | `+send-receipt`、`+decline-receipt`、`+signature` |
|
||||
| `DS-Mail-005` | P1 | missing capability | 持续监听新邮件 | unavailable | Event + Mail service | `+watch` |
|
||||
| `DS-Mail-006` | P1 | contract insufficient | 带附件/内联图片的模板创建更新 | partial | Mail + Drive adapters | 完整 `+template-create/update` |
|
||||
| `DS-Mail-007` | P1 | adapter defect | 创建联系人并取得稳定身份 | blocked | Mail adapter | `+contact-create/update/delete` |
|
||||
| `DS-Mail-008` | P1 | adapter defect | 一致的成功、空结果与分页合同 | partial | Mail adapter | 全部 list/search Shortcut |
|
||||
| `DS-Mail-009` | P1 | tenant-or-fixture | 安全验证发送、回执、分享和监听 | blocked | Product QA / tenant admin | 全部高影响 Mail Shortcut |
|
||||
| `DS-Mail-010` | P0 | contract insufficient | 草稿/模板可证明的清理终态 | blocked | Mail service / adapter | `+draft-create/edit`、`+template-create/update` |
|
||||
|
||||
## 2. 用户任务与能力缺口总览
|
||||
|
||||
| 用户任务 / Golden Route | DWS Shortcut | Lark CLI 对应 | 当前能力 | 缺口分类 | 临时处置 |
|
||||
|---|---|---|---|---|---|
|
||||
| 浏览/筛选摘要 | `+triage`、`+search-mail` | `+triage` | covered | 无 | 公开,严格分页 |
|
||||
| 固定未读/近期列表 | `+unread-mail`、`+recent-mail` | Lark 对应任务入口 | blocked | 固定查询/文件夹缺可控 guaranteed-zero fixture | 保持 unavailable |
|
||||
| 读取一封、多封、会话 | `+message`、`+messages`、`+thread` | 同名入口 | covered | 无 | 公开,精确 ID 读回 |
|
||||
| 新建/编辑草稿 | `+draft-create`、`+draft-edit` | 同名入口 | blocked | 两次 batch-delete 后同 ID 仍可读,无法证明零残留 | 保持 unavailable |
|
||||
| 创建/更新基础模板 | `+template-create`、`+template-update` | 同名入口 | blocked | delete 后 get 没有 typed nonfound;from/isDraft 也不可读回 | 保持 unavailable |
|
||||
| 发送新邮件/已有草稿 | 无公开 Shortcut;存在 raw send | `+send`、`+draft-send` | partial | 终态、逐项结果、幂等不足 | 保持 raw,不宣称对齐 |
|
||||
| 回复/回复全部/转发 | 无公开 Shortcut;raw 路径会立即发送 | `+reply`、`+reply-all`、`+forward` | partial | 缺少默认草稿与邮件头保真合同 | 保持 raw,不宣称对齐 |
|
||||
| 修改/删除邮件 | 无公开 Shortcut;存在 raw batch route | `+message-modify`、`+message-trash` | partial | 无逐项 ledger 和严格终态 | 保持 raw,不宣称对齐 |
|
||||
| 发送/拒绝已读回执 | 无 | `+send-receipt`、`+decline-receipt` | unavailable | 专用业务接口与标签合同缺失 | 明确不可用 |
|
||||
| 邮箱签名 | 无 | `+signature` | unavailable | 签名读取接口缺失 | 明确不可用 |
|
||||
| 分享邮件到聊天 | raw 高风险入口 | `+share-to-chat` | partial | 缺安全 fixture、逐目标结果与读回 | 不公开 Shortcut |
|
||||
| HTML lint | 无 | `+lint-html` | unavailable | 缺统一邮件 HTML 规则包 | 下游或本地规则能力需求 |
|
||||
| 监听新邮件 | 无公开 Mail Shortcut | `+watch` | unavailable | 订阅生命周期和安全事件合同不足 | 不公开 Shortcut |
|
||||
| 文件夹/标签/联系人/企业邮箱用户 | `+folder-list`、`+user-search`、`+find-mail-user` 公开;其余列表不公开 | 无同名任务入口 | partial DWS extra | 标签/模板/联系人/会话列表缺安全双态 fixture | 无双态证据的入口保持 unavailable |
|
||||
|
||||
## 3. 下游需求明细
|
||||
|
||||
### `DS-Mail-001` — 可验证的发送生命周期
|
||||
|
||||
- 用户任务:发送新邮件或一个/多个草稿,并知道每一封最终是成功、失败、部分成功还是状态未知。
|
||||
- 当前证据:raw 发送可返回业务 success 或发送标识,但不能统一证明最终投递;批量草稿发送没有逐项 ledger、请求顺序、未知提交和安全重试合同。
|
||||
- 所需接口合同:
|
||||
- 创建/发送必须返回稳定 `messageId` 与 `internetMessageId`,并明确 `accepted/pending/sent/partial_failure/failure/unknown`。
|
||||
- 提供按同一身份查询发送状态的接口;状态必须绑定请求邮件与收件人集合。
|
||||
- 批量发送返回逐项结果,任何一项失败时整体不得退出 0 冒充全成功。
|
||||
- 支持幂等键,或明确 unknown commit 不可自动重试。
|
||||
- 失败错误区分参数、权限、风控、限流、收件人拒收和提交未知。
|
||||
- 验收:安全自发自收 fixture 完成 draft-create → exact get → send → 状态终态 → sent-folder exact read;批量中注入一项失败,验证 ledger 与非零整体结果;清理无测试草稿残留。
|
||||
|
||||
### `DS-Mail-002` — 回复/转发的草稿优先与 MIME 保真
|
||||
|
||||
- 用户任务:回复、回复全部或转发一封邮件,默认保存草稿,只有再次确认才发送。
|
||||
- 当前证据:DWS raw route 会创建回复/转发草稿后立即发送,无法对齐 Lark 的默认草稿语义;上游也无法证明 `In-Reply-To`、`References`、原始引用块和收件人集合正确。
|
||||
- 所需接口合同:
|
||||
- 独立 `create_reply_draft`、`create_reply_all_draft`、`create_forward_draft`,返回稳定草稿 ID,不隐式发送。
|
||||
- 服务端生成并可读回线程关系头、回复全部去重后的 To/CC、转发引用块和附件继承结果。
|
||||
- 发送必须复用 `DS-Mail-001` 的确认、终态和幂等合同。
|
||||
- 验收:用隔离自发邮件分别创建三类草稿,精确 ID 读回核对父邮件、参与人集合和引用语义;未确认时远程发送调用为 0;确认发送后状态终态可验证。
|
||||
|
||||
### `DS-Mail-003` — 邮件修改、移动和删除的逐项终态
|
||||
|
||||
- 用户任务:批量标记已读/未读、增删标签、移动文件夹、软删除邮件。
|
||||
- 当前证据:raw batch route 多数只给聚合 success;删除后邮件仍可能可读,无法区分“移入已删除文件夹”“永久删除”“延迟可见”或“未生效”。
|
||||
- 所需接口合同:
|
||||
- 每个输入 messageId 返回 `applied/already_applied/failed/unknown` 与稳定原因码。
|
||||
- 修改/移动后详情或摘要必须可读回 `isRead/tags/folderId`;删除返回明确 tombstone 或 folder transition。
|
||||
- 软删除和永久删除使用不同操作,危险级别与确认要求可声明。
|
||||
- 任何部分失败整体 outcome 为 `partial_failure` 且进程非零。
|
||||
- 验收:创建隔离邮件,执行 mark-unread/read、标签增删、移动与软删除,每步同 ID 读回;错误 ID 与合法 ID 混合时逐项 ledger 完整且整体非零。
|
||||
|
||||
### `DS-Mail-004` — 已读回执与签名
|
||||
|
||||
- 用户任务:识别邮件是否请求回执;确认后发送标准回执,或拒绝并清除提示;列出和查看默认签名。
|
||||
- 当前证据:现有 Mail 接口没有稳定暴露回执请求标签、专用发送/拒绝操作或签名读取资源,上游无法安全组合普通回复替代。
|
||||
- 所需接口合同:
|
||||
- 消息详情公开稳定回执请求状态和请求者身份类型。
|
||||
- 专用 send/decline receipt 操作,幂等且返回状态;正文由服务端生成,不能让上游伪造。
|
||||
- 签名列表/详情返回稳定 ID、默认发送场景、HTML/文本内容和敏感字段标注。
|
||||
- 验收:预置请求回执邮件,未确认零写调用;发送/拒绝后状态读回且重复调用幂等;签名已知非空与合法空均可证明。
|
||||
|
||||
### `DS-Mail-005` — 新邮件监听的订阅生命周期
|
||||
|
||||
- 用户任务:在限定时间内监听新邮件,得到稳定、可恢复、可去重的事件流。
|
||||
- 当前证据:通用事件基础设施不能证明 Mail scope、订阅状态、ready marker、断线续传和消息读取权限形成完整任务链。
|
||||
- 所需接口合同:订阅/查询/退订;明确 user/bot 身份、scope 和租户开关;ready marker;事件 `eventId/messageId/mailbox/time`;断线 cursor、去重和界限参数;心跳不冒充业务事件。
|
||||
- 验收:隔离邮箱订阅后注入一封测试邮件,只收到一次并能以 messageId 精确读取;超时、权限缺失、断线重连和退订后零事件均有确定结果。
|
||||
|
||||
### `DS-Mail-006` — 模板附件与内联图片事务
|
||||
|
||||
- 用户任务:创建或更新含普通附件、内联图片和 HTML 的模板,同时保留未修改 MIME 结构。
|
||||
- 当前证据:本轮只对齐名称、主题、正文核心字段;现有多步上传缺少模板级事务、附件稳定 ID、失败回滚和更新时的结构保真证明。
|
||||
- 所需接口合同:创建/更新草稿会话、附件上传会话、content-id 映射、提交/取消;返回逐附件 ledger;更新提供版本或 etag,避免 last-write-wins 覆盖;失败可回滚且无孤儿文件。
|
||||
- 验收:普通附件和内联图片各一,创建后按模板 ID 读取附件 ID/名称/大小/content-id;更新正文不丢附件;中途失败自动取消并证明零孤儿资源。
|
||||
|
||||
### `DS-Mail-007` — 联系人写操作的稳定身份
|
||||
|
||||
- 用户任务:创建、更新、删除个人邮件联系人并验证精确对象。
|
||||
- 当前证据:真实 create 返回 `success=true` 但没有 contactId;上游只能用随机显示名再扫列表定位,无法用于一般用户输入,因为名称/邮箱可能重复。
|
||||
- 所需接口合同:create 返回稳定 contactId;get-by-id;update/delete 返回同 ID 与版本;列表支持 exact email 或 ID filter;重复联系人规则明确。
|
||||
- 验收:创建回执直接得到 ID,get-by-id 精确核对,更新同 ID,删除后 not-found/tombstone;重复邮箱和同名联系人有稳定结果而非猜测。
|
||||
|
||||
### `DS-Mail-008` — 统一成功、空结果与分页协议
|
||||
|
||||
- 用户任务:可靠地区分“确实没有结果”“还有下一页”“服务异常或响应漂移”。
|
||||
- 当前证据:同一产品的 success 同时出现布尔和字符串;hasMore 也出现两种编码;搜索终页用 `$`,部分列表用空串;零命中邮件会返回 `total=0` 加一个只有空收件人字段的占位对象。当前租户又没有空邮箱或空邮件文件夹,不能为无筛选列表证明 guaranteed-zero。
|
||||
- 所需接口合同:
|
||||
- success 与 hasMore 统一为布尔;所有列表显式数组,合法空只返回 `[]`。
|
||||
- 统一 `nextCursor` 与 `endpointExhausted`;终页不使用业务哨兵对象或魔法值。
|
||||
- 每项稳定 ID 必填;total 使用整数;服务错误必须 `success=false` 和稳定错误码。
|
||||
- 保留兼容期,但提供 capability/version 让上游安全切换。
|
||||
- 验收:每个列表/搜索执行已知非空、保证零命中、坏 item、缺集合、错型、hasMore 无游标、重复游标;只有显式合法空成功。
|
||||
|
||||
### `DS-Mail-009` — 安全租户与真实 E2E fixture
|
||||
|
||||
- 用户任务:在不触达真实业务收件人和内容的前提下验证所有高影响 Mail Shortcut。
|
||||
- 所需 fixture:隔离自发自收邮箱、可控第二收件人、回执请求邮件、可分享的测试聊天、安全事件订阅、测试签名、可回收附件;所有资源用随机无业务含义标记并有自动清理。
|
||||
- 权限:最小 Mail read/write/event、Drive attachment、IM share scopes 分离;可测试 user/bot 差异和缺权限错误。
|
||||
- 验收:stdout 只输出 PASS 标签与聚合计数;原始 JSON 只在临时目录;finally 清理;远端零测试草稿/模板/联系人/邮件/订阅残留;仓库和历史扫描无身份数据。
|
||||
|
||||
### `DS-Mail-010` — 草稿/模板可证明的清理终态
|
||||
|
||||
- 用户任务:用可回收 fixture 验证草稿与模板写 Shortcut,不留下无法确认的远端测试对象。
|
||||
- 当前证据:草稿创建/更新回执和 exact-ID 读回成功,但同一 ID 连续两次 batch-delete 后仍可读;模板 delete 返回成功后,get 仅为未分类失败,既非 typed nonfound 也不能证明 tombstone。
|
||||
- 所需接口合同:分离软删除与永久删除;返回稳定 ID、终态和幂等证据;get-by-id 对已永久删除对象返回稳定 `not_found/deleted` 错误或已审核 tombstone,不得空 body、通用失败或继续返回对象。
|
||||
- 验收:create/update → exact-ID readback → permanent delete → exact Shortcut + raw get 双层 typed absence;有界轮询后仍可读或终态未知时整体非零,且不得发布 Shortcut。
|
||||
- 临时处置:四个写 Shortcut 保持 `public=false` / `unavailable`,直到安全 fixture 与 typed absence 同时可证明。
|
||||
|
||||
## 4. Lark 对齐与平台差异
|
||||
|
||||
| Lark 用户任务 | 可精确对齐 | 平台差异 | DWS 推荐结论 |
|
||||
|---|---|---|---|
|
||||
| `+message` / `+messages` / `+thread` / `+triage` | yes | DWS 额外自动解析邮箱和收件箱,并严格发布完整性 | 已公开 |
|
||||
| `+draft-create` / `+draft-edit` | blocked | 核心写回可证,但删除后同 ID 仍可读,无安全清理终态 | 不公开,保持 unavailable |
|
||||
| `+template-create` / `+template-update` | blocked | 核心字段可读回,但 from/isDraft 不可验且删除后缺 typed nonfound | 不公开,保持 unavailable |
|
||||
| `+send` / `+draft-send` | no | DWS raw 偏立即发送且缺统一终态/逐项 ledger | 暂不公开 Shortcut |
|
||||
| `+reply` / `+reply-all` / `+forward` | no | DWS raw 会立即发送,Lark 默认保存草稿 | 暂不公开 Shortcut |
|
||||
| `+message-modify` / `+message-trash` | no | 聚合 success 不足以证明逐项终态 | 暂不公开 Shortcut |
|
||||
| `+send-receipt` / `+decline-receipt` | no | 缺专用接口和可验证标签 | platform unavailable |
|
||||
| `+signature` | no | 缺签名读取资源 | platform unavailable |
|
||||
| `+watch` | no | 缺完整订阅生命周期与安全 fixture | fixture + capability blocked |
|
||||
| `+share-to-chat` | partial | raw 可调用但缺逐目标验证和安全 fixture | 保持 raw |
|
||||
| `+lint-html` | no | DWS 未提供统一规则包 | downstream/local capability needed |
|
||||
|
||||
## 5. 超越 Lark 的产品机会
|
||||
|
||||
| 产品原生能力 | 可形成的 DWS Shortcut | 安全/验证要求 | 优先级 |
|
||||
|---|---|---|---|
|
||||
| 文件夹、标签与联系人目录 | `+organize`:规则化移动、标记与标签组合 | 逐项 ledger、写后读回、补偿恢复 | P1 |
|
||||
| 收信规则、白名单、黑名单、自动回复 | `+inbox-policy-audit` | 只读汇总优先;写操作强确认和版本化 | P2 |
|
||||
| 邮箱日历 | `+mail-calendar-conflicts` | 与主 Calendar 的 ownership boundary 明确,禁止双写 | P2 |
|
||||
| 发送状态与召回 | `+delivery-audit` | 终态、收件人粒度、召回结果和不可逆提示 | P1 |
|
||||
| 附件导出与分享 | `+archive-message` | 精确 messageId、原子本地写入、敏感路径与清理 | P2 |
|
||||
|
||||
## 6. 无需下游变更的上游修复
|
||||
|
||||
| Shortcut | 上游根因 | 已完成修复 | 回归证据 |
|
||||
|---|---|---|---|
|
||||
| 全部 list/search | 容忍式探测任意 result/data/list/items,坏元素静默丢弃 | 固定已观测路径、严格 success/数组/item/ID;无双态 fixture 的 leaf 不发布 | deterministic 响应矩阵;live 证据逐 leaf 记录,不作泛化 |
|
||||
| `+search-mail` / `+triage` | `$` 终止游标被误作下一页;零命中占位对象被当邮件 | 明确 `$` 终页;仅窄规则归一化已观测哨兵 | 各完成 known-nonempty 20;3 个 fresh 零命中 raw 均为 `total=0` + 无稳定 ID/正文且收件字段全空的 reviewed sentinel + terminal cursor,exact 才归一化为显式 `[]`;不把该下游特例描述成 raw 空数组 |
|
||||
| `+search-mail` / `+triage` 自动邮箱解析 | 严格化时只接受顶层对象数组,会拒绝历史已观测的字符串数组和 `result/data.emailAccounts` 包装 | 仅接受三个审核路径 `emailAccounts` / `result.emailAccounts` / `data.emailAccounts`,每项可为非空邮箱字符串或含非空 `email` 的对象;缺集合、错型、坏项或多路径冲突全部 fail-closed;空发件人也不再投影为空字符串成功 | top/result/data × string/object、blank/wrong/multiple-path 与 sender missing/null/wrong-type 回归覆盖;最终 live 未传 `--email` 执行 `+search-mail`/`+triage`,owning 响应为顶层 object-item 形态并成功解析 |
|
||||
| `+unread-mail` / `+recent-mail` / `+thread-list` | 固定条件或文件夹不能保证零命中 | 严格响应代码已完成,但没有空邮箱/空文件夹证据时关闭发布 | BLOCKED fixture;不得修改真实邮件状态造空 |
|
||||
| `+user-search` / `+find-mail-user` | `hasMore`/`nextCursor` 未交付;零命中被误报 validation error | 发布 complete/nextCursor;合法空成功 | 各完成 known-nonempty 20 + fresh raw 显式空;stable identity set 与 raw pagination/meta 精确一致;`+user-search` 同轮实跑历史 string `--limit` |
|
||||
| `+tag-list` / `+template-list` / `+contact-list` | 无 query 的列表容易把末页/删除后列表误作合法空 | 严格响应代码已完成;无专用空邮箱和 typed cleanup 时关闭发布 | BLOCKED fixture;不把临时资源从列表消失记为零态 PASS |
|
||||
| `+message(s)` / `+thread` | 缺任务层完整读取和身份绑定 | 自动邮箱解析、精确请求 ID 读回、保序多读 | `+message`/`+thread` 与同稳定 ID raw 完整对象一致;`+messages` 用两个不同 ID 验证输入顺序与逐对象一致 |
|
||||
| 草稿/模板写 | 仅写回执会产生假成功 | 稳定 ID + exact get + 请求字段核对;清理无法证明时保持 unavailable | deterministic 回执/读回矩阵 PASS;live cleanup BLOCKED |
|
||||
|
||||
### 6.1 clean executable HEAD 双层证据
|
||||
|
||||
| 公开入口 | exact Shortcut + owning raw 证据 | 状态 |
|
||||
|---|---|---|
|
||||
| `+search-mail`, `+triage` | 各 20 条 known-nonempty;3 个独立 fresh 零命中由 raw `total=0`、无稳定 ID/正文的单 sentinel 与 terminal cursor 共同证明,exact 严格归一化为显式空;稳定 message ID 集合和分页状态一致 | `PASS_WITH_REVIEWED_ZERO_ENCODING`;最终 SHA 见 PR 证据 |
|
||||
| `+user-search`, `+find-mail-user` | 各 20 条 known-nonempty 与 raw 显式 fresh zero;条件身份集合和分页状态一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+folder-list` | 顶层 5 条 nonempty;本轮先由 raw 验证同一父文件夹确实为空,再由 Shortcut 返回显式空;ID 集合一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
| `+message`, `+messages`, `+thread` | 单邮件/会话同稳定 ID 完整对象一致;批量用两个不同 ID 验证请求顺序和逐对象一致 | `PASS`;最终 SHA 见 PR 证据 |
|
||||
|
||||
8 个公开入口均在最终 clean runtime tree 从零重跑;其中 6 个使用标准 raw 显式空或精确对象证据,2 个邮件搜索使用上述审核过的下游零命中 sentinel 编码。最终可执行 SHA 写入 PR 证据,本文只保留脱敏业务断言。
|
||||
|
||||
## 7. 安全与脱敏声明
|
||||
|
||||
- 本文不含用户、组织、租户、profile、邮箱、人员姓名、邮件/会话/模板/联系人/聊天真实 ID。
|
||||
- 本文不含邮件主题正文、收发件人、trace/request ID、token、签名 URL、电话或真实业务时间。
|
||||
- 真实 E2E 原始响应仅在仓库外临时目录解析;普通输出只保留能力标签、计数和布尔断言。
|
||||
- 临时草稿虽已执行两次 batch-delete 但仍可按同 ID 读取;临时模板删除后也未获得 typed nonfound。两者都不记为清理 PASS,四个写 Shortcut 因此保持 unavailable。
|
||||
- 当前邮箱没有已验证的空邮件文件夹或专用空邮箱;因此 `+unread-mail`、`+recent-mail`、`+thread-list`、`+tag-list`、`+template-list`、`+contact-list` 不记 live 双态 PASS,并保持 unavailable。
|
||||
- 最终提交前仍需扫描最终树、未跟踪文件和 `origin/main..HEAD` 全部历史。
|
||||
+434
-150
@@ -1,7 +1,67 @@
|
||||
{
|
||||
"generated_at": "2026-08-19T10:35:58.304269",
|
||||
"count": 423,
|
||||
"generated_at": "2026-08-24T20:14:49.172788",
|
||||
"count": 437,
|
||||
"results": [
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+contract-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、字段数组和每项稳定 id;本地 keyword 覆盖字段标识、编码、标题、分类和类型,支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+obj-template-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result 数组、稳定模板 ID 及 page/pageSize/totalCount;不虚构 cursor。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-statistics-list",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 数组和每项稳定 templateId;关键词同时支持已知非空与合法零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+report-submit-detail",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、嵌套稳定用户身份及 page/pageSize/totalCount;下游忽略 keyword,因此做有界全量遍历、本地过滤、停滞/重复/总数变化失败与人员字段最小投影。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "agoal",
|
||||
"command": "+user-rules",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、content 对象、rules 数组和稳定 ruleId;本地精确 ruleId 选择器支持已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aisearch",
|
||||
"command": "+search-person",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "对 enterprise_person_search 增加显式 success/result 数组、坏元素、来源类型和稳定人员身份校验;exact live 已同时证明已知非空与 phone 维度不可存在号码的显式零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -272,6 +332,76 @@
|
||||
"semantic_delta": "更新仪表盘配置的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-config",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "读取已有数据源表的同步配置详情(源配置、字段选择、自动同步状态)的一对一入口。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-get-fields",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键),用于决定 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-list-sources",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "列出指定 Base 下可用的数据源条目(OA 审批模板等),提取 processCode/name/iconUrl/url 用于 sourceConfig。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-sync-status",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
"command": "+datasource-update",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "schema_leaf",
|
||||
"semantic_delta": "更新已有数据源表的同步配置并触发一次同步;不改配置可只切换 auto 开关或 field-ids。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "aitable",
|
||||
@@ -2263,263 +2393,394 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+by-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "使用专用手机号精确查询接口解析稳定 userId;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,未命中返回 typed nonzero;命中后读取并精确核对同一用户详情,null、错型、坏身份或详情 ID 不一致均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按部门名唯一解析 deptId 后列直属成员;搜索候选和成员集合均逐项严格校验,绝不猜测多匹配。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-dept-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按 deptId 列直属成员,严格要求显式 deptUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-followings",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、result.models 数组、对象元素、唯一稳定 openDingTalkId;可用 --open-id 做本地精确筛选,exact live 已证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-role-members",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按角色列成员,严格要求 success、显式 labelUserList、userInfo 对象及稳定 userId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "contact",
|
||||
"command": "+list-roles",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+list-sub-depts",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按父部门列直属子部门,严格要求显式 result 数组与有效 deptId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+lookup",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名唯一解析稳定 userId 后读取并核对唯一用户详情;零命中和多命中均错误关闭。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+me",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "读取当前用户唯一详情并严格要求 orgEmployeeModel 与稳定 userId,再投影最小自身份字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+org",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户、核对用户详情与主 deptId,再读取并核对部门详情的稳定身份。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+resolve-dept",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按名称返回唯一 deptId 或显式候选;严格要求 deptList 数组、有效且不重复的 deptId 与部门名。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-mobile",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "使用专用手机号精确查询接口取得稳定 userId,并直接投影该接口返回的受审身份字段,不额外依赖用户详情权限;专用接口 success=true 且省略 result 是经真实双层验证的精确零命中编码,null、空对象、数组或坏身份均失败。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+search-user",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按姓名搜索并严格要求 success、显式 result 数组、非空对象和稳定 userId/openDingTalkId;已验证非空与随机零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "contact",
|
||||
"command": "+team",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "按姓名解析用户和主部门后列直属成员;每一步校验 success、稳定身份及显式成员集合。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+create",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建后提取稳定 unifiedAppId,并以同一 ID 读回名称及请求字段;只有精确核验通过才返回成功。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+credentials-get",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success、稳定 unifiedAppId、非空客户端标识和非空 secret;Result 将密钥路径声明为敏感。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+delete",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "删除前读回稳定 appKey,删除后按该选择器有界遍历全部页并证明同一 unifiedAppId 不再存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+disable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "停用后按同一 unifiedAppId 读回并要求 appStatus=disabled。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+enable",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "启用后按同一 unifiedAppId 读回并要求 appStatus=normal。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、事件数组、每项稳定 eventCode 与游标终止证据;明确返回可用事件目录及订阅状态,并拒绝坏元素与伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+event-subscribe",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值与重复 eventCode,写后有界遍历订阅列表并逐项精确读回。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空响应、缺失 success 和空业务对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、应用数组、稳定 unifiedAppId 与游标终止证据;投影当前页并保留可续翻 meta.pagination。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-add",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后按稳定 userId 逐项精确读回,并要求 memberType 与请求角色一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、成员数组和每项稳定 userId;新增本地精确 userId 选择器以证明已知非空与保证零命中。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+member-remove",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空值和重复 userId,校验写终态后读取完整成员数组,并逐项证明目标稳定 userId 已不存在。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+permission-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、权限数组、每项稳定 scopeValue 与游标终止证据,拒绝坏元素和伪空结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-config",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空配置,写后按同一 unifiedAppId 读取机器人对象并精确比较全部请求标量字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-disable",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并读回 robotStatus=UNCONFIGURED;不虚构保留配置或可直接恢复语义。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-enable",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求写终态成功并按同一 unifiedAppId 读回 robotStatus=ONLINE。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+robot-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空机器人配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+update",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "要求至少一个更新字段,写入后按同一 unifiedAppId 精确读回所有请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-check-approval",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "只执行 precheckOnly,严格绑定应用与版本身份,并保留可执行后续动作的 pending 结果。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-create",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "创建回执必须含稳定 versionId,随后以 unifiedAppId/versionId 双身份读取详情并核验请求字段;live fixture 通过删除临时父应用清理。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空版本对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、版本数组、每项稳定 versionId 与游标终止证据。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+version-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空发布状态对象,并要求 unifiedAppId 和 versionId 同时与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-config",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "拒绝空更新,写入后按同一 unifiedAppId 读取网页配置并精确比较全部请求字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "devapp",
|
||||
"command": "+webapp-get",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证非空网页配置对象,并要求读回 unifiedAppId 与请求精确一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "ding",
|
||||
"command": "+list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+recall-personal",
|
||||
"risk": "high-risk-write",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "ding",
|
||||
"command": "+receiver-status",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "write",
|
||||
"service": "ding",
|
||||
"command": "+send-personal",
|
||||
"risk": "write",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按稳定 openDingId 精确查询,严格拒绝缺集合、错型、空集合、坏元素与身份不匹配;current HEAD exact Shortcut 与 owning atomic/raw 的请求身份、1 项结果和完整接收行集合一致。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3602,81 +3863,84 @@
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-cc",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-executed",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-pending",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+list-submitted",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"service": "oa",
|
||||
"command": "+my-initiated",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "oa",
|
||||
"command": "+search-forms",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "按关键字搜索可发起审批定义,严格要求显式 result 数组和稳定 processCode;已完成已知非空与保证零命中证明。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "pat",
|
||||
"command": "+browser-policy",
|
||||
"risk": "write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "在隔离本地策略文件上提供显式确认、无写入请求预览、同目标磁盘读回和不暴露 agent identity 的统一结果;exact 写入与清理已通过。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+inbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证 success、result.report_list、稳定 reportId 与 hasMore/cursor;current HEAD exact 与 owning atomic 同场景已知页均为 20 项、稳定身份集合和 next cursor 一致,独立未来范围均为 0 且明确终止。终止页回显 cursor 只作已验证收据且不发布 next_token。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+outbox-list",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格验证发件箱集合、稳定 reportId 和分页终止证据;current HEAD exact 与 owning atomic 同场景已知页均为 1 项且身份一致,独立未来范围均为 0 并明确终止。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+report-latest",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "完整验证默认最近 20 天或显式不超过 20 天的发件箱;current HEAD exact 所选稳定 reportId 与 owning atomic 候选和精确详情身份一致,严格详情字段计数双层均为 3。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "report",
|
||||
"command": "+template-search",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "在严格验证完整可用模板集合、稳定 templateId 与名称后执行本地不区分大小写搜索;current HEAD exact 与 owning atomic 完整集合过滤的已知结果均为 1 且身份一致,随机 UUID 查询均为 0。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+list-sheets",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格要求 success=true、显式 sheets 数组、非空且唯一的 sheetId 与标题;提供完整标题本地精确筛选,因此可分别证明已知非空和合法零命中,未知结构绝不降级为空数组。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "read",
|
||||
"suite": "semantic",
|
||||
"service": "sheet",
|
||||
"command": "+read",
|
||||
"risk": "read",
|
||||
"status": "real-ok"
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格校验 success、二维 cells、行列坐标与完成证据;服务返回 hasMore=true 时因没有可执行续页游标而失败关闭,保留 Sheet 读取与 AITable/Base 记录查询的产品边界。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
@@ -3888,6 +4152,26 @@
|
||||
"semantic_delta": "更新指定字段后读取详情逐字段核验。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+query",
|
||||
"risk": "read",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "semantic_adapter",
|
||||
"semantic_delta": "严格投影要求 success=true、OpenNodes V1、显式 pages 数组、每页稳定 id 与显式 nodes 数组,并校验跨页节点身份及服务端完整性摘要。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "whiteboard",
|
||||
"command": "+update",
|
||||
"risk": "high-risk-write",
|
||||
"status": "reviewed_available",
|
||||
"disposition": "primary_smart",
|
||||
"semantic_delta": "首次远端调用前完成 OpenNodes V1 校验与用户确认;写后要求 success=true、同一目标、非空终态回执、createdNodeIds/idMap 精确映射,再按真实节点身份独立 query 读回请求关键字段。",
|
||||
"availability": "available"
|
||||
},
|
||||
{
|
||||
"suite": "semantic",
|
||||
"service": "wiki",
|
||||
|
||||
@@ -10,6 +10,7 @@ require (
|
||||
github.com/charmbracelet/bubbletea v1.3.6
|
||||
github.com/charmbracelet/huh v1.0.0
|
||||
github.com/charmbracelet/lipgloss v1.1.0
|
||||
github.com/creack/pty v1.1.24
|
||||
github.com/fatih/color v1.18.0
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
|
||||
@@ -372,7 +372,7 @@ func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t
|
||||
{path: "chat +conversation-category-list", candidates: []string{"chat +category-list", "chat +category-list-conversations"}},
|
||||
{path: "chat +conversation-group-list", candidates: []string{"chat +category-list-conversations", "chat +conversation-list"}},
|
||||
{path: "chat +list-my-groups", candidates: []string{"chat +my-groups", "chat +chat-list-mine", "chat +chat-list"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"}},
|
||||
{path: "oa +list-processes", candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
|
||||
@@ -1295,6 +1295,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
hasTarget := false
|
||||
for _, id := range subscribeIDs {
|
||||
if strings.TrimSpace(id) != "" {
|
||||
hasTarget = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasTarget {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
|
||||
@@ -734,7 +734,7 @@ func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
func TestCrossPlatformCoverageStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
@@ -746,6 +746,18 @@ func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
t.Fatal("targeted stop called without a subscribe_id")
|
||||
return transport.ConsumerStopResp{}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried without a subscribe_id")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"", " "}); err != nil {
|
||||
t.Fatalf("empty target stop = %v", err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy replaces the
|
||||
// old helpers-only AST scan with an invariant over the two real assembly
|
||||
// products: the deterministic distribution tree and a runtime tree after a
|
||||
// nested plugin overlay has been merged.
|
||||
func TestCrossPlatformCoverageFinalCommandTreesDeclareGroupPolicy(t *testing.T) {
|
||||
distribution := NewSchemaSourceRootCommand()
|
||||
for _, path := range []string{
|
||||
"sheet range read",
|
||||
"pat chmod",
|
||||
"plugin list",
|
||||
"chat +chat-messages",
|
||||
} {
|
||||
requireFinalCommandPath(t, distribution, path)
|
||||
}
|
||||
if err := cobracmd.ValidateGroupTree(distribution); err != nil {
|
||||
t.Fatalf("distribution command tree GroupPolicy invariant: %v", err)
|
||||
}
|
||||
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
testseam.Swap(t, &rootLoadPlugins, func(root *cobra.Command, _ *pipeline.Engine, runner executor.Runner) []*cobra.Command {
|
||||
descriptor := conferencePluginDescriptor()
|
||||
return buildPluginCommands([]mcptypes.ServerDescriptor{descriptor}, runner, root)
|
||||
})
|
||||
runtime := NewRootCommand()
|
||||
requireFinalCommandPath(t, runtime, "conference camera open")
|
||||
if err := cobracmd.ValidateGroupTree(runtime); err != nil {
|
||||
t.Fatalf("runtime command tree GroupPolicy invariant: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func requireFinalCommandPath(t *testing.T, root *cobra.Command, path string) *cobra.Command {
|
||||
t.Helper()
|
||||
command, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || command == nil || len(remaining) != 0 || command == root {
|
||||
t.Fatalf("final command path %q not assembled: command=%v remaining=%v err=%v", path, command, remaining, err)
|
||||
}
|
||||
return command
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageSheetWhiteboardMarkdownRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, root)
|
||||
assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t, root, tools)
|
||||
assertMarkdownDriveRoutesStayCrossProduct(t, root, tools)
|
||||
assertWhiteboardPublicShortcutsStayAvailableInSchema(t, root, tools)
|
||||
}
|
||||
|
||||
func assertMarkdownLarkTasksRouteWithoutDuplicateShortcuts(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
registered := 0
|
||||
for _, item := range shortcut.All() {
|
||||
if item.Service == "markdown" {
|
||||
registered++
|
||||
}
|
||||
}
|
||||
if registered != 0 {
|
||||
t.Fatalf("registered Markdown Shortcuts=%d, want 0: existing composite leaves own these workflows", registered)
|
||||
}
|
||||
|
||||
type route struct {
|
||||
canonical string
|
||||
confirmation string
|
||||
flags []string
|
||||
}
|
||||
routes := map[string]route{
|
||||
"create": {
|
||||
canonical: "markdown.create", confirmation: "not_required",
|
||||
flags: []string{"content", "file", "folder", "name", "space-id", "workspace"},
|
||||
},
|
||||
"fetch": {
|
||||
canonical: "markdown.fetch", confirmation: "not_required",
|
||||
flags: []string{"node", "output", "space-id", "workspace"},
|
||||
},
|
||||
"overwrite": {
|
||||
canonical: "markdown.overwrite", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "file", "name", "node", "space-id", "workspace"},
|
||||
},
|
||||
"patch": {
|
||||
canonical: "markdown.patch", confirmation: "user_required",
|
||||
flags: []string{"content", "dry-run", "node", "pattern", "regex", "space-id", "workspace"},
|
||||
},
|
||||
"diff": {
|
||||
canonical: "markdown.diff", confirmation: "not_required",
|
||||
flags: []string{"context", "file", "node", "version", "version2"},
|
||||
},
|
||||
}
|
||||
|
||||
group := mustFindCommand(t, root, "markdown")
|
||||
children := map[string]bool{}
|
||||
for _, child := range group.Commands() {
|
||||
children[child.Name()] = true
|
||||
}
|
||||
if len(children) != len(routes) {
|
||||
t.Fatalf("Markdown ordinary leaves=%v, want exactly five routed workflows", children)
|
||||
}
|
||||
|
||||
for name, want := range routes {
|
||||
leaf := mustFindCommand(t, root, "markdown", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("markdown %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !children[name] {
|
||||
t.Errorf("markdown %s is not mounted on the ordinary product group", name)
|
||||
}
|
||||
for _, flag := range want.flags {
|
||||
if leaf.Flags().Lookup(flag) == nil {
|
||||
t.Errorf("markdown %s is missing routed flag --%s", name, flag)
|
||||
}
|
||||
}
|
||||
if shortcut.InPublicCatalog("markdown", "+"+name) {
|
||||
t.Errorf("markdown +%s unexpectedly entered the public Shortcut catalog", name)
|
||||
}
|
||||
|
||||
meta, ok := cli.ResolveMeta("markdown " + name)
|
||||
if !ok {
|
||||
t.Errorf("markdown %s missing from assembled Schema", name)
|
||||
continue
|
||||
}
|
||||
if meta.Identity.Canonical != want.canonical || meta.Identity.CLIPath != "markdown "+name {
|
||||
t.Errorf("markdown %s identity=%#v, want canonical=%q cli_path=%q", name, meta.Identity, want.canonical, "markdown "+name)
|
||||
}
|
||||
if meta.Safety.Confirmation != want.confirmation {
|
||||
t.Errorf("markdown %s confirmation=%q, want %q", name, meta.Safety.Confirmation, want.confirmation)
|
||||
}
|
||||
|
||||
tool := tools[want.canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("markdown %s missing from full delivery Schema", name)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("markdown %s availability=%q, want available", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("markdown %s interface_mode=%q, want composite", name, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("markdown %s is missing the reviewed composite routing reason", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownDriveRoutesStayCrossProduct(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
driveShortcuts := map[string]string{
|
||||
"+copy": "drive.shortcut_copy",
|
||||
"+delete": "drive.shortcut_delete",
|
||||
"+find-file": "drive.shortcut_find_file",
|
||||
"+list": "drive.shortcut_list",
|
||||
"+move": "drive.shortcut_move",
|
||||
"+publish-get": "drive.shortcut_publish_get",
|
||||
"+recycle-restore": "drive.shortcut_recycle_restore",
|
||||
"+rename": "drive.shortcut_rename",
|
||||
"+version-download": "drive.shortcut_version_download",
|
||||
"+version-get": "drive.shortcut_version_get",
|
||||
"+version-history": "drive.shortcut_version_history",
|
||||
"+version-revert": "drive.shortcut_version_revert",
|
||||
}
|
||||
for name, canonical := range driveShortcuts {
|
||||
leaf := mustFindCommand(t, root, "drive", name)
|
||||
if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("drive %s hidden/runnable=%v/%v, want false/true", name, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
if !shortcut.InPublicCatalog("drive", name) {
|
||||
t.Errorf("drive %s is not in the public Shortcut catalog", name)
|
||||
}
|
||||
assertMarkdownCrossProductRoute(t, tools, "drive "+name, canonical)
|
||||
}
|
||||
|
||||
ordinaryRoutes := map[string]string{
|
||||
"drive permission list": "drive.list_permission",
|
||||
"drive pull": "drive.folder_pull",
|
||||
"drive push": "drive.folder_push",
|
||||
"drive status": "drive.folder_status",
|
||||
"drive sync": "drive.folder_sync",
|
||||
"wiki node list": "wiki.list_nodes",
|
||||
}
|
||||
for cliPath, canonical := range ordinaryRoutes {
|
||||
assertMarkdownCrossProductRoute(t, tools, cliPath, canonical)
|
||||
}
|
||||
}
|
||||
|
||||
func assertMarkdownCrossProductRoute(t *testing.T, tools map[string]map[string]any, cliPath, canonical string) {
|
||||
t.Helper()
|
||||
meta, ok := cli.ResolveMeta(cliPath)
|
||||
if !ok {
|
||||
t.Errorf("cross-product route %q is missing from assembled Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if meta.Identity.Canonical != canonical || meta.Identity.CLIPath != cliPath {
|
||||
t.Errorf("cross-product route %q identity=%#v, want canonical=%q", cliPath, meta.Identity, canonical)
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("cross-product route %q is missing from full delivery Schema", cliPath)
|
||||
return
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("cross-product route %q availability=%q, want available", cliPath, got)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
@@ -43,6 +44,11 @@ func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(group, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
group.AddCommand(newMCPURLGetCommand(caller))
|
||||
return group
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
)
|
||||
|
||||
func TestOAFinalSchemaAvailabilityMatchesReviewedExecution(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
for _, canonical := range []string{
|
||||
"oa.shortcut_approve_by",
|
||||
"oa.shortcut_done_approvals",
|
||||
"oa.shortcut_list_cc",
|
||||
"oa.shortcut_list_executed",
|
||||
"oa.shortcut_list_forms",
|
||||
"oa.shortcut_list_pending",
|
||||
"oa.shortcut_list_submitted",
|
||||
"oa.shortcut_my_initiated",
|
||||
"oa.shortcut_pending",
|
||||
"oa.shortcut_search_forms",
|
||||
} {
|
||||
tool, ok := snapshot.Tools[canonical]
|
||||
if !ok {
|
||||
t.Errorf("final Schema lacks OA tool %s", canonical)
|
||||
continue
|
||||
}
|
||||
if got := tool["availability"]; got != contract.InterfaceAvailable {
|
||||
t.Errorf("%s final availability=%v, want %q", canonical, got, contract.InterfaceAvailable)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -68,6 +68,34 @@ func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string
|
||||
return `{"success":true,"result":[]}`
|
||||
case "list_suggested_event_times":
|
||||
return `{"success":true,"result":{"recommendEventTimes":[]}}`
|
||||
case "list_by_keyword_and_time_range":
|
||||
return `{"success":true,"result":{"itemList":[{"taskUuid":"u1","startTime":1}]}}`
|
||||
case "get_minutes_basic_info":
|
||||
return `{"success":true,"result":{"taskUuid":"u1","title":"Fixture Minutes"}}`
|
||||
case "get_minutes_transcription":
|
||||
return `{"success":true,"result":{"paragraphList":[],"hasNext":false}}`
|
||||
case "create_personal_todo":
|
||||
return `{"success":true,"result":{"taskId":"task-1"}}`
|
||||
case "get_todo_detail":
|
||||
return `{"success":true,"result":{"todoDetailModel":{"taskId":"task-1","subject":"Fixture Todo","isDone":false}}}`
|
||||
case "get_user_todos_in_current_org":
|
||||
return `{"success":true,"result":{"todoCards":[],"hasMore":false}}`
|
||||
case "add_todo_reminder":
|
||||
return `{"success":true}`
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1"}`
|
||||
case "move_document", "add_member", "update_member", "remove_member":
|
||||
return `{"success":true}`
|
||||
case "get_document_info":
|
||||
if len(c.calls) > 1 {
|
||||
switch c.calls[len(c.calls)-2].tool {
|
||||
case "copy_document":
|
||||
return `{"success":true,"nodeId":"copy-1","workspaceId":"workspace-1","folderId":"folder-1"}`
|
||||
case "move_document":
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"drive-1","folderId":"folder-1"}`
|
||||
}
|
||||
}
|
||||
return `{"success":true,"nodeId":"node-1","workspaceId":"source-1","folderId":"source-folder"}`
|
||||
case "create_calendar_event":
|
||||
return `{"success":true,"result":{"eventId":"event-1"}}`
|
||||
case "update_calendar_event", "delete_calendar_event", "add_calendar_participant", "remove_calendar_participant":
|
||||
|
||||
@@ -148,12 +148,12 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"contact +search-user": {"contact", "+search-user", "--query", "Fixture User"},
|
||||
"contact dept list-children": {"contact", "dept", "list-children", "--dept", "1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1"},
|
||||
"contact user profile get": {"contact", "user", "profile", "get", "--staff-id", "user-1", "--fields", "name,userId"},
|
||||
"dev app get": {"dev", "app", "get", "--unified-app-id", "app-1"},
|
||||
"devdoc article search": {"devdoc", "article", "search", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"ding +receiver-status": {"ding", "+receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message receiver-status": {"ding", "message", "receiver-status", "--ding-id", "ding-1"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1", "--yes"},
|
||||
"ding message send": {"ding", "message", "send", "--robot-code", "robot-1", "--content", "fixture", "--users", "user-1"},
|
||||
"doc +comment-create": {"doc", "+comment-create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc +comment-list": {"doc", "+comment-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +comment-reply": {"doc", "+comment-reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--yes"},
|
||||
@@ -221,21 +221,196 @@ var paramAliasCompleteCommands = map[string][]string{
|
||||
"drive search": {"drive", "search", "--query", "fixture", "--created-from", "1", "--created-to", "2", "--modified-from", "3", "--modified-to", "4", "--creator-uids", "user-1,user-2"},
|
||||
"drive upload": {"drive", "upload", "--file", "../../go.mod", "--space-id", "space-1"},
|
||||
"drive upload-info": {"drive", "upload-info", "--file-name", "fixture.txt", "--file-size", "7", "--space-id", "space-1"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder", "--yes"},
|
||||
"mail +find-mail-user": {"mail", "+find-mail-user", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail folder update": {"mail", "folder", "update", "--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder"},
|
||||
"mail message search": {"mail", "message", "search", "--email", "fixture@example.com", "--query", "subject:fixture"},
|
||||
"mail thread list": {"mail", "thread", "list", "--email", "fixture@example.com", "--folder", "folder-1", "--limit", "7"},
|
||||
"mail user search": {"mail", "user", "search", "--keyword", "fixture"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "1"},
|
||||
"oa +search-forms": {"oa", "+search-forms", "--query", "fixture"},
|
||||
"oa approval search-forms": {"oa", "approval", "search-forms", "--query", "fixture"},
|
||||
"report list": {"report", "list", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
}
|
||||
|
||||
// paramAliasCandidateCompleteCommands contains complete invocations for
|
||||
// reviewed parameter-concept product drafts. Keeping candidate-only commands
|
||||
// in a separate map lets a test change land before a draft replaces the formal
|
||||
// param_concepts.json: inactive candidate templates are ignored, while every
|
||||
// command becomes mandatory as soon as one of its reviewed aliases is active.
|
||||
var paramAliasCandidateCompleteCommands = map[string][]string{
|
||||
"aisearch": {"aisearch", "--query", "Fixture User", "--dimension", "name"},
|
||||
"aisearch +search-person": {"aisearch", "+search-person", "--query", "Fixture User", "--dimensions", "name"},
|
||||
"aisearch behavior": {"aisearch", "behavior", "--queries", "fixture", "--types", "im", "--behavior-type", "send", "--chat-scope", "Fixture Group", "--direction", "我->Fixture User", "--time-range", "本周"},
|
||||
"aisearch enterprise": {"aisearch", "enterprise", "--queries", "fixture", "--types", "document", "--time-range", "本周"},
|
||||
"aisearch person": {"aisearch", "person", "--query", "Fixture User", "--dimension", "name"},
|
||||
"contact +by-mobile": {"contact", "+by-mobile", "--mobile", "13800138000"},
|
||||
"contact +list-dept-members": {"contact", "+list-dept-members", "--depts", "1,2"},
|
||||
"contact +list-followings": {"contact", "+list-followings", "--open-id", "open-fixture-1"},
|
||||
"contact +list-role-members": {"contact", "+list-role-members", "--id", "12345"},
|
||||
"contact +lookup": {"contact", "+lookup", "--name", "Fixture User"},
|
||||
"contact +org": {"contact", "+org", "--name", "Fixture User"},
|
||||
"contact +search-mobile": {"contact", "+search-mobile", "--mobile", "13800138000"},
|
||||
"contact +team": {"contact", "+team", "--name", "Fixture User"},
|
||||
"contact account create": {"contact", "account", "create", "--login-id", "fixture-login", "--org-user-name", "Fixture User", "--dept-ids", "1,2"},
|
||||
"contact account update": {"contact", "account", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--avatar-file-id", "file-1", "--yes"},
|
||||
"contact dept create": {"contact", "dept", "create", "--name", "Fixture Dept", "--parent", "1", "--create-dept-group", "--yes"},
|
||||
"contact dept get-info": {"contact", "dept", "get-info", "--dept", "1"},
|
||||
"contact dept list-members": {"contact", "dept", "list-members", "--depts", "1,2"},
|
||||
"contact dept search": {"contact", "dept", "search", "--query", "Fixture Dept"},
|
||||
"contact dept update": {"contact", "dept", "update", "--dept", "2", "--name", "Fixture Dept", "--parent", "1", "--yes"},
|
||||
"contact label get": {"contact", "label", "get", "--names", "Fixture Role"},
|
||||
"contact org create": {"contact", "org", "create", "--org-name", "Fixture Org", "--creator-username", "Fixture Creator"},
|
||||
"contact user dismission search": {"contact", "user", "dismission", "search", "--depts", "1,2", "--start", "2026-03-01", "--end", "2026-03-31", "--page", "2", "--limit", "7"},
|
||||
"contact user get": {"contact", "user", "get", "--ids", "user-1,user-2"},
|
||||
"contact user invite": {"contact", "user", "invite", "--org-user-mobile", "13800138000", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`},
|
||||
"contact user search": {"contact", "user", "search", "--query", "Fixture User"},
|
||||
"contact user search-mobile": {"contact", "user", "search-mobile", "--mobile", "13800138000"},
|
||||
"contact user update": {"contact", "user", "update", "--user-id", "user-1", "--org-user-name", "Fixture User", "--depts", `[{"deptId":1}]`, "--yes"},
|
||||
"contact user update-ownness": {"contact", "user", "update-ownness", "--user-id", "user-1", "--ownness-text", "Fixture Status", "--yes"},
|
||||
"contact user update-self": {"contact", "user", "update-self", "--avatar-file-id", "file-1", "--nick", "Fixture Nick", "--yes"},
|
||||
"devdoc +search-docs": {"devdoc", "+search-docs", "--query", "fixture", "--page", "2", "--size", "7"},
|
||||
"hrbrain +get-pool": {"hrbrain", "+get-pool", "--pool-code", "pool-1"},
|
||||
"hrbrain +list-pool-employees": {"hrbrain", "+list-pool-employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +list-pools": {"hrbrain", "+list-pools", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +profile-career": {"hrbrain", "+profile-career", "--work-no", "work-1"},
|
||||
"hrbrain +profile-labels": {"hrbrain", "+profile-labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain +profile-metadata": {"hrbrain", "+profile-metadata", "--work-no", "work-1"},
|
||||
"hrbrain +profile-performance": {"hrbrain", "+profile-performance", "--work-no", "work-1"},
|
||||
"hrbrain +query-profile": {"hrbrain", "+query-profile", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain +search-employees": {"hrbrain", "+search-employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain +search-employees-structured": {"hrbrain", "+search-employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain profile career": {"hrbrain", "profile", "career", "--work-no", "work-1"},
|
||||
"hrbrain profile labels": {"hrbrain", "profile", "labels", "--staff-ids", "work-1,work-2", "--all-label"},
|
||||
"hrbrain profile metadata": {"hrbrain", "profile", "metadata", "--work-no", "work-1"},
|
||||
"hrbrain profile performance": {"hrbrain", "profile", "performance", "--work-no", "work-1"},
|
||||
"hrbrain profile query": {"hrbrain", "profile", "query", "--work-no", "work-1", "--data-queries", `[{"modelCode":"basic","fields":["name"]}]`},
|
||||
"hrbrain search employees": {"hrbrain", "search", "employees", "--keyword", "fixture", "--dept-name", "Fixture Dept", "--position-name", "Engineer", "--job-level", "P7", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain search employees-structured": {"hrbrain", "search", "employees-structured", "--origin-json", `{"rules":[],"combinator":"and"}`, "--fields", `[{"label":"name","value":"name"}]`, "--order-by", "name", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool detail": {"hrbrain", "talent-pool", "detail", "--pool-code", "pool-1"},
|
||||
"hrbrain talent-pool employees": {"hrbrain", "talent-pool", "employees", "--pool-code", "pool-1", "--page", "2", "--page-size", "7"},
|
||||
"hrbrain talent-pool list": {"hrbrain", "talent-pool", "list", "--keyword", "fixture", "--labels", "label-a,label-b", "--page", "2", "--page-size", "7"},
|
||||
"pat +browser-policy": {"pat", "+browser-policy", "--enabled=false", "--agent-code", "fixture-agent", "--dry-run"},
|
||||
"pat browser-policy": {"pat", "browser-policy", "--enabled=false", "--agentCode", "fixture-agent"},
|
||||
"pat chmod": {"pat", "chmod", "--product", "calendar", "--products", "aitable", "--domain", "chat", "--domains", "mail", "--grant-type", "session", "--session-id", "session-1", "--recommend", "--agentCode", "fixture-agent", "--dry-run"},
|
||||
"attendance +check-record": {"attendance", "+check-record", "--users", "user-1,user-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-adjustment-rule": {"attendance", "+get-adjustment-rule", "--adjustment-id", "adjustment-1"},
|
||||
"attendance +get-approve-template": {"attendance", "+get-approve-template", "--type", "leave"},
|
||||
"attendance +get-checkin-record": {"attendance", "+get-checkin-record", "--operator-corp-id", "corp-1", "--operator-staff-id", "staff-operator", "--staff-ids", "staff-1,staff-2", "--start", "2026-03-10 00:00:00", "--end", "2026-03-10 23:59:59"},
|
||||
"attendance +get-leave-records": {"attendance", "+get-leave-records", "--user", "user-1", "--start", "2026-03-01", "--end", "2026-03-31", "--leave-code", "annual_leave"},
|
||||
"attendance +get-overtime-rule": {"attendance", "+get-overtime-rule", "--overtime-id", "overtime-1"},
|
||||
"attendance +get-schedule": {"attendance", "+get-schedule", "--users", "user-1,user-2", "--start", "2026-03-10", "--end", "2026-03-11"},
|
||||
"attendance +get-self-setting": {"attendance", "+get-self-setting", "--user", "user-1", "--setting-scene", "checkRemind"},
|
||||
"attendance +get-summary": {"attendance", "+get-summary", "--user", "user-1", "--date", "2026-03-10", "--stats-type", "week"},
|
||||
"attendance +list-approve": {"attendance", "+list-approve", "--users", "user-1,user-2", "--types", "leave", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +query-report-data": {"attendance", "+query-report-data", "--users", "user-1,user-2", "--columns", "attendance_days,late_count", "--start", "2026-03-01", "--end", "2026-03-31"},
|
||||
"attendance +search-adjustment-rule": {"attendance", "+search-adjustment-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"attendance +search-class": {"attendance", "+search-class", "--filter-type", "name", "--query", "fixture"},
|
||||
"attendance +search-group": {"attendance", "+search-group", "--type", "FIXED"},
|
||||
"attendance +search-overtime-rule": {"attendance", "+search-overtime-rule", "--query", "fixture", "--page", "2", "--limit", "7"},
|
||||
"ding +list": {"ding", "+list", "--cursor", "0", "--type", "ALL"},
|
||||
"ding +recall-personal": {"ding", "+recall-personal", "--id", "ding-1", "--yes"},
|
||||
"ding +send-personal": {"ding", "+send-personal", "--users", appFixtureCurrentDOpenID, "--content", "fixture", "--yes"},
|
||||
"mail +contact-list": {"mail", "+contact-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +folder-list": {"mail", "+folder-list", "--email", "fixture@example.com", "--folder", "folder-1"},
|
||||
"mail +message": {"mail", "+message", "--email", "fixture@example.com", "--id", "message-1"},
|
||||
"mail +messages": {"mail", "+messages", "--email", "fixture@example.com", "--ids", "message-1,message-2"},
|
||||
"mail +recent-mail": {"mail", "+recent-mail", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +search-mail": {"mail", "+search-mail", "--query", "fixture", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +template-list": {"mail", "+template-list", "--email", "fixture@example.com", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +thread": {"mail", "+thread", "--email", "fixture@example.com", "--id", "thread-1"},
|
||||
"mail +thread-list": {"mail", "+thread-list", "--email", "fixture@example.com", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"mail +triage": {"mail", "+triage", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"mail +unread-mail": {"mail", "+unread-mail", "--size", "7", "--cursor", "cursor-1"},
|
||||
"mail +user-search": {"mail", "+user-search", "--keyword", "fixture", "--cursor", "cursor-1"},
|
||||
"markdown create": {"markdown", "create", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
"markdown diff": {"markdown", "diff", "--node", "node-1", "--version", "1", "--version2", "2", "--context", "3"},
|
||||
"markdown fetch": {"markdown", "fetch", "--node", "node-1", "--space-id", "space-1", "--output", "/tmp/dws-markdown-fixture.md"},
|
||||
"markdown overwrite": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"markdown patch": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--space-id", "space-1", "--yes"},
|
||||
"oa +list-cc": {"oa", "+list-cc", "--page", "2"},
|
||||
"oa +list-executed": {"oa", "+list-executed", "--limit", "7", "--page", "2"},
|
||||
"oa +list-forms": {"oa", "+list-forms", "--cursor", "2"},
|
||||
"oa +list-pending": {"oa", "+list-pending", "--start", "1773072000000", "--end", "1773158399000", "--page", "2"},
|
||||
"oa +list-submitted": {"oa", "+list-submitted", "--page", "2"},
|
||||
"oa +my-initiated": {"oa", "+my-initiated", "--page", "2"},
|
||||
"report +outbox-list": {"report", "+outbox-list", "--size", "7"},
|
||||
"report +report-latest": {"report", "+report-latest", "--keyword", "Fixture", "--start", "2026-03-01T00:00:00+08:00", "--end", "2026-03-10T00:00:00+08:00"},
|
||||
"report +template-search": {"report", "+template-search", "--query", "fixture"},
|
||||
"sheet +list-sheets": {"sheet", "+list-sheets", "--node", "node-1"},
|
||||
"sheet +read": {"sheet", "+read", "--node", "node-1", "--sheet-id", "Sheet1"},
|
||||
|
||||
"minutes +detail": {"minutes", "+detail", "--ids", "u1,u2"},
|
||||
"minutes +latest": {"minutes", "+latest", "--keyword", "fixture"},
|
||||
"minutes +list-all": {"minutes", "+list-all", "--limit", "7"},
|
||||
"minutes +record-pause": {"minutes", "+record-pause", "--id", "u1", "--yes"},
|
||||
"minutes +replace-batch": {"minutes", "+replace-batch", "--id", "u1", "--pair", "old=>new", "--yes"},
|
||||
"minutes +search": {"minutes", "+search", "--query", "fixture", "--cursor", "cursor-1"},
|
||||
"minutes +share": {"minutes", "+share", "--ids", "u1,u2", "--member-uids", "user-1,user-2", "--permission", "view", "--yes"},
|
||||
"minutes +speaker-replace": {"minutes", "+speaker-replace", "--id", "u1", "--from", "old", "--to", "new", "--target-uid", "user-1", "--yes"},
|
||||
"minutes +summary": {"minutes", "+summary", "--id", "u1", "--content", "fixture", "--yes"},
|
||||
"minutes +transcript": {"minutes", "+transcript", "--keyword", "fixture"},
|
||||
"minutes +upload-and-analyze": {"minutes", "+upload-and-analyze", "--resume-id", "u1", "--yes"},
|
||||
"minutes audio-memo list": {"minutes", "audio-memo", "list", "--max", "7"},
|
||||
"minutes get batch": {"minutes", "get", "batch", "--ids", "u1,u2"},
|
||||
"minutes hot-word add": {"minutes", "hot-word", "add", "--words", "DWS,Minutes"},
|
||||
"minutes list all": {"minutes", "list", "all", "--end", "2026-03-10T23:59:59+08:00"},
|
||||
"minutes list mine": {"minutes", "list", "mine", "--start", "2026-03-10T00:00:00+08:00"},
|
||||
"minutes replace-text": {"minutes", "replace-text", "--id", "u1", "--search", "old", "--replace", "new"},
|
||||
"minutes tag query": {"minutes", "tag", "query", "--tag-id", "tag-1"},
|
||||
"minutes update title": {"minutes", "update", "title", "--id", "u1", "--title", "Fixture Minutes"},
|
||||
"minutes upload complete": {"minutes", "upload", "complete", "--session-id", "session-1"},
|
||||
"todo +assign": {"todo", "+assign", "--task", "Fixture Todo", "--to", "Fixture User", "--yes"},
|
||||
"todo +assign-multi": {"todo", "+assign-multi", "--task", "Fixture Todo", "--to", "Fixture User,User Two", "--yes"},
|
||||
"todo +comment": {"todo", "+comment", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo +complete": {"todo", "+complete", "--task-id", "task-1", "--yes"},
|
||||
"todo +create": {"todo", "+create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +due-today": {"todo", "+due-today", "--role-types", "executor"},
|
||||
"todo +get-my-tasks": {"todo", "+get-my-tasks", "--role-types", "executor", "--priority", "40", "--page", "2", "--size", "7"},
|
||||
"todo +get-related-tasks": {"todo", "+get-related-tasks", "--role-types", "creator,executor", "--status", "false"},
|
||||
"todo +list-comment": {"todo", "+list-comment", "--task-id", "task-1", "--page", "2"},
|
||||
"todo +remind": {"todo", "+remind", "--task", "Fixture Todo", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reminder": {"todo", "+reminder", "--task-id", "task-1", "--base-time", "customTime", "--at", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo +reopen": {"todo", "+reopen", "--task-id", "task-1", "--yes"},
|
||||
"todo +search": {"todo", "+search", "--query", "fixture", "--status", "false"},
|
||||
"todo +todo-done": {"todo", "+todo-done", "--task", "Fixture Todo", "--yes"},
|
||||
"todo +update": {"todo", "+update", "--task-id", "task-1", "--title", "Fixture Updated Todo", "--yes"},
|
||||
"todo comment add": {"todo", "comment", "add", "--task-id", "task-1", "--content", "fixture comment", "--yes"},
|
||||
"todo comment list": {"todo", "comment", "list", "--task-id", "task-1", "--page", "2", "--size", "7"},
|
||||
"todo task add-executor": {"todo", "task", "add-executor", "--task-id", "task-1", "--executors", "user-1,user-2", "--yes"},
|
||||
"todo task add-participant": {"todo", "task", "add-participant", "--task-id", "task-1", "--participants", "user-1,user-2", "--yes"},
|
||||
"todo task add-reminder": {"todo", "task", "add-reminder", "--task-id", "task-1", "--base-time", "customTime", "--reminder-time-stamp", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create": {"todo", "task", "create", "--title", "Fixture Todo", "--executors", "user-1,user-2", "--due", "2026-03-10T18:00:00+08:00", "--yes"},
|
||||
"todo task create-sub": {"todo", "task", "create-sub", "--parent-id", "task-parent", "--title", "Fixture Sub Todo", "--executors", "user-1", "--yes"},
|
||||
"todo task done": {"todo", "task", "done", "--task-id", "task-1", "--status", "true", "--yes"},
|
||||
"todo task get": {"todo", "task", "get", "--task-id", "task-1"},
|
||||
"todo task list": {"todo", "task", "list", "--role-types", "executor", "--page", "2", "--size", "7"},
|
||||
"todo task update": {"todo", "task", "update", "--task-id", "task-1", "--done", "true", "--yes"},
|
||||
"wiki +member-add": {"wiki", "+member-add", "--workspace", "workspace-1", "--user", "user-1", "--role", "READER", "--yes"},
|
||||
"wiki +member-remove": {"wiki", "+member-remove", "--workspace", "workspace-1", "--user", "user-1", "--yes"},
|
||||
"wiki +member-update": {"wiki", "+member-update", "--workspace", "workspace-1", "--user", "user-1", "--role", "EDITOR", "--yes"},
|
||||
"wiki +move": {"wiki", "+move", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +move-to-drive": {"wiki", "+move-to-drive", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-copy": {"wiki", "+node-copy", "--workspace", "workspace-1", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"wiki +node-delete": {"wiki", "+node-delete", "--workspace", "workspace-1", "--node", "node-1", "--yes"},
|
||||
}
|
||||
|
||||
// A command can expose more than one mutually exclusive canonical route. In
|
||||
// that case the shared command template above cannot contain every canonical
|
||||
// flag at once, so select a fixture-specific complete invocation here.
|
||||
var paramAliasCompleteCommandVariants = map[string]map[string][]string{
|
||||
"markdown create": {
|
||||
"file": {"markdown", "create", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1"},
|
||||
},
|
||||
"markdown diff": {
|
||||
"file": {"markdown", "diff", "--node", "node-1", "--file", "../../README.md", "--context", "3"},
|
||||
},
|
||||
"markdown overwrite": {
|
||||
"file": {"markdown", "overwrite", "--node", "node-1", "--file", "../../README.md", "--name", "fixture.md", "--space-id", "space-1", "--yes"},
|
||||
"dry-run": {"markdown", "overwrite", "--node", "node-1", "--content", "# Fixture", "--name", "fixture.md", "--space-id", "space-1", "--dry-run"},
|
||||
},
|
||||
"markdown patch": {
|
||||
"dry-run": {"markdown", "patch", "--node", "node-1", "--pattern", "old", "--content", "new", "--regex", "--dry-run"},
|
||||
},
|
||||
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
@@ -531,6 +706,20 @@ var paramAliasNewConfirmationCases = []struct {
|
||||
{command: "drive +version-revert", emitted: "version-number", canonical: "version"},
|
||||
}
|
||||
|
||||
// Candidate confirmation cases become active with the joint draft. One write
|
||||
// workflow per product plus TODO's reminder workflow proves semantic aliasing
|
||||
// cannot move execution across the shared --yes barrier.
|
||||
var paramAliasCandidateConfirmationCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "minutes +record-pause", emitted: "uuid", canonical: "id"},
|
||||
{command: "todo +create", emitted: "deadline", canonical: "due"},
|
||||
{command: "todo +reminder", emitted: "reminder-time-stamp", canonical: "at"},
|
||||
{command: "wiki +node-copy", emitted: "node-id", canonical: "node"},
|
||||
}
|
||||
|
||||
// paramAliasRepresentativePayloadCases keeps final transport coverage across
|
||||
// old concept aliases, command overrides, native compatibility flags, read and
|
||||
// write commands, and different products. Every reviewed alias is still
|
||||
@@ -600,6 +789,30 @@ var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
// Candidate representatives exercise the final transport boundary for each
|
||||
// Minutes/TODO/Wiki alias family. They are required only when the exact fixture
|
||||
// exists in the loaded reviewed table, so the tests are mergeable before the
|
||||
// joint draft is promoted to internal/cli/param_concepts.json.
|
||||
var paramAliasCandidateRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("minutes +latest", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes +transcript", "query"): true,
|
||||
paramAliasPayloadCaseKey("minutes get batch", "uuids"): true,
|
||||
paramAliasPayloadCaseKey("minutes update title", "task-uuid"): true,
|
||||
paramAliasPayloadCaseKey("minutes upload complete", "upload-id"): true,
|
||||
paramAliasPayloadCaseKey("todo +create", "deadline"): true,
|
||||
paramAliasPayloadCaseKey("todo +get-my-tasks", "current-page"): true,
|
||||
paramAliasPayloadCaseKey("todo +reminder", "reminder-time-stamp"): true,
|
||||
paramAliasPayloadCaseKey("todo comment add", "text"): true,
|
||||
paramAliasPayloadCaseKey("todo task add-executor", "executor-ids"): true,
|
||||
paramAliasPayloadCaseKey("todo task get", "todo-id"): true,
|
||||
paramAliasPayloadCaseKey("todo task update", "status"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-add", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-remove", "uid"): true,
|
||||
paramAliasPayloadCaseKey("wiki +member-update", "user-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +move-to-drive", "node-id"): true,
|
||||
paramAliasPayloadCaseKey("wiki +node-copy", "node-id"): true,
|
||||
}
|
||||
|
||||
// paramAliasCalendarPayloadCases keeps the full reviewed Calendar expansion
|
||||
// separate from the long-lived app-c race process. Each case still executes
|
||||
// both canonical and alias argv through the real PreParse/Cobra path and
|
||||
@@ -709,6 +922,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeFixtureCases := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
@@ -717,6 +931,8 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
activeFixtureCases[caseKey] = true
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
t.Errorf("reviewed active fixture %q/%q has no complete-command E2E template", fixture.Command, fixture.Emitted)
|
||||
@@ -729,8 +945,7 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] && !paramAliasCandidateRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
@@ -742,26 +957,43 @@ func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepres
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
templateCommands := make(map[string]bool, len(paramAliasCompleteCommands)+len(paramAliasCandidateCompleteCommands))
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
templateCommands[command] = true
|
||||
}
|
||||
for command := range paramAliasCandidateCompleteCommands {
|
||||
if activeCommands[command] {
|
||||
templateCommands[command] = true
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
if !templateCommands[command] {
|
||||
t.Errorf("active reviewed command %q has no complete-command E2E template", command)
|
||||
}
|
||||
}
|
||||
if len(activeCommands) != len(paramAliasCompleteCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(paramAliasCompleteCommands), len(activeCommands), activeCases)
|
||||
if len(activeCommands) != len(templateCommands) {
|
||||
t.Fatalf("complete-command coverage = %d templates for %d active commands (%d active cases)", len(templateCommands), len(activeCommands), activeCases)
|
||||
}
|
||||
for caseKey := range paramAliasRepresentativePayloadCases {
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("representative final-payload case %q has no active reviewed fixture", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != len(paramAliasRepresentativePayloadCases) {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), len(paramAliasRepresentativePayloadCases))
|
||||
activeRepresentatives := len(paramAliasRepresentativePayloadCases)
|
||||
for caseKey := range paramAliasCandidateRepresentativePayloadCases {
|
||||
if !activeFixtureCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
activeRepresentatives++
|
||||
if !executedRepresentatives[caseKey] {
|
||||
t.Errorf("candidate representative final-payload case %q was not executed", caseKey)
|
||||
}
|
||||
}
|
||||
if len(executedRepresentatives) != activeRepresentatives {
|
||||
t.Fatalf("representative final-payload coverage = %d, want %d", len(executedRepresentatives), activeRepresentatives)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -852,6 +1084,122 @@ func assertParamAliasCannotBypassConfirmation(t *testing.T, aliasArgs []string)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation
|
||||
// exercises every distinct reviewed mutating complete-command template in the
|
||||
// reviewed product expansions. The fixture gate already proves every
|
||||
// alias resolves through PreParse; this gate removes the confirmation flag
|
||||
// from one active alias invocation per distinct template and requires the
|
||||
// runtime boundary to stop it before the first transport call. An explicit
|
||||
// --dry-run is a reviewed preview path and must not carry a bypass flag.
|
||||
func TestCrossPlatformCoverageReviewedProductTemplatedParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
requiredTemplates := make(map[string]bool)
|
||||
coveredTemplates := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
product, _, _ := strings.Cut(fixture.Command, " ")
|
||||
switch product {
|
||||
case "attendance", "mail", "oa", "ding", "report", "sheet", "whiteboard", "markdown",
|
||||
"aisearch", "contact", "live", "devdoc", "hrbrain", "pat":
|
||||
default:
|
||||
continue
|
||||
}
|
||||
complete, ok := paramAliasCompleteCommand(fixture.Command, fixture.Expect)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
_, yesCount := removeExactArg(complete, "--yes")
|
||||
_, userSayYesCount := removeExactArg(complete, "--user-say-yes")
|
||||
confirmationCount := yesCount + userSayYesCount
|
||||
confirmationArg := "--yes"
|
||||
if userSayYesCount == 1 {
|
||||
confirmationArg = "--user-say-yes"
|
||||
}
|
||||
_, dryRunCount := removeExactArg(complete, "--dry-run")
|
||||
if dryRunCount > 1 {
|
||||
t.Errorf("template must contain --dry-run at most once: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
if meta, exists := cli.ResolveMeta(fixture.Command); exists {
|
||||
switch meta.Safety.Confirmation {
|
||||
case "user_required":
|
||||
if dryRunCount == 1 {
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-confirmed dry-run template must not contain a confirmation bypass flag: command=%q args=%v", fixture.Command, complete)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("Schema-confirmed template must contain exactly one reviewed confirmation flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
case "not_required":
|
||||
if confirmationCount != 0 {
|
||||
t.Errorf("Schema-unconfirmed template must not contain a confirmation bypass flag: command=%q confirmation=%q args=%v", fixture.Command, meta.Safety.Confirmation, complete)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if confirmationCount == 0 {
|
||||
continue
|
||||
}
|
||||
if confirmationCount != 1 {
|
||||
t.Errorf("confirmation template must contain exactly one reviewed confirmation flag: command=%q args=%v", fixture.Command, complete)
|
||||
continue
|
||||
}
|
||||
|
||||
templateKey := fixture.Command + "\x00" + strings.Join(complete, "\x00")
|
||||
requiredTemplates[templateKey] = true
|
||||
if coveredTemplates[templateKey] {
|
||||
continue
|
||||
}
|
||||
aliasArgs, replacements := replaceLongFlag(complete, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("confirmation template for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, complete)
|
||||
continue
|
||||
}
|
||||
coveredTemplates[templateKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
assertTemplatedParamAliasCannotBypassConfirmation(t, fixture.Command, confirmationArg, aliasArgs)
|
||||
})
|
||||
}
|
||||
|
||||
if len(requiredTemplates) == 0 {
|
||||
t.Fatal("reviewed complete-command templates contain no confirmation cases")
|
||||
}
|
||||
if len(coveredTemplates) != len(requiredTemplates) {
|
||||
t.Fatalf("templated confirmation coverage = %d, want %d", len(coveredTemplates), len(requiredTemplates))
|
||||
}
|
||||
}
|
||||
|
||||
func assertTemplatedParamAliasCannotBypassConfirmation(t *testing.T, command, confirmationArg string, aliasArgs []string) {
|
||||
t.Helper()
|
||||
unconfirmedArgs, removals := removeExactArg(aliasArgs, confirmationArg)
|
||||
if removals != 1 {
|
||||
t.Fatalf("confirmation template must contain %s exactly once; removals=%d args=%v", confirmationArg, removals, aliasArgs)
|
||||
}
|
||||
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasPayloadE2E(t, caller, unconfirmedArgs...)
|
||||
if ctx == nil {
|
||||
t.Fatal("unconfirmed alias command skipped PreParse")
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if errors.As(err, &appErr) && appErr.Reason == "confirmation_required" {
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("unconfirmed alias crossed the transport boundary before confirmation: args=%v calls=%#v", unconfirmedArgs, caller.calls)
|
||||
}
|
||||
return
|
||||
}
|
||||
t.Fatalf("unconfirmed alias command error = %#v, want confirmation_required\ncommand=%q args=%v calls=%#v", err, command, unconfirmedArgs, caller.calls)
|
||||
}
|
||||
|
||||
func assertParamAliasFinalPayloadEquivalent(t *testing.T, command string, canonicalArgs, aliasArgs []string) {
|
||||
t.Helper()
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
@@ -1091,7 +1439,20 @@ func TestCrossPlatformCoverageNewAITableDeleteDisableAliasesPreserveConfirmation
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewParamAliasesCannotBypassConfirmation(t *testing.T) {
|
||||
for _, test := range paramAliasNewConfirmationCases {
|
||||
tests := append([]struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{}, paramAliasNewConfirmationCases...)
|
||||
for _, candidate := range paramAliasCandidateConfirmationCases {
|
||||
entry, exists := cli.LookupParamAlias(candidate.command)
|
||||
target, active := entry.ResolveAlias(candidate.emitted)
|
||||
if exists && active && target == candidate.canonical {
|
||||
tests = append(tests, candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
@@ -1173,6 +1534,10 @@ func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
if ok {
|
||||
return complete, true
|
||||
}
|
||||
complete, ok = paramAliasCandidateCompleteCommands[command]
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
@@ -747,7 +748,11 @@ func pruneEmptyPluginGroups(parent *cobra.Command) {
|
||||
}
|
||||
for _, child := range append([]*cobra.Command(nil), parent.Commands()...) {
|
||||
pruneEmptyPluginGroups(child)
|
||||
if cmdutil.IsGroup(child) && len(child.Commands()) == 0 {
|
||||
_, group, err := corecmd.GroupPolicyFor(child)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("prune plugin group %q: %v", child.CommandPath(), err))
|
||||
}
|
||||
if group && len(child.Commands()) == 0 {
|
||||
parent.RemoveCommand(child)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -30,6 +31,42 @@ func (pluginWrongFlagValue) String() string { return "" }
|
||||
func (pluginWrongFlagValue) Set(string) error { return nil }
|
||||
func (pluginWrongFlagValue) Type() string { return "wrong" }
|
||||
|
||||
func TestCrossPlatformCoveragePruneEmptyPluginGroupsRejectsMalformedPolicy(t *testing.T) {
|
||||
emptyParent := &cobra.Command{Use: "plugin"}
|
||||
emptyGroup := &cobra.Command{Use: "empty"}
|
||||
corecmd.ApplyGroupPolicy(emptyGroup, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
emptyParent.AddCommand(emptyGroup)
|
||||
pruneEmptyPluginGroups(emptyParent)
|
||||
if len(emptyParent.Commands()) != 0 {
|
||||
t.Fatalf("empty plugin group was not pruned: %#v", emptyParent.Commands())
|
||||
}
|
||||
|
||||
parent := &cobra.Command{Use: "plugin"}
|
||||
child := &cobra.Command{Use: "group"}
|
||||
corecmd.ApplyGroupPolicy(child, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
for key := range child.Annotations {
|
||||
child.Annotations[key] = "malformed"
|
||||
}
|
||||
parent.AddCommand(child)
|
||||
|
||||
defer func() {
|
||||
got := recover()
|
||||
message, ok := got.(string)
|
||||
if !ok || !strings.Contains(message, "prune plugin group") {
|
||||
t.Fatalf("pruneEmptyPluginGroups panic = %v", got)
|
||||
}
|
||||
}()
|
||||
pruneEmptyPluginGroups(parent)
|
||||
}
|
||||
|
||||
func TestPluginCompilerRejectsInvalidDuplicateAndEmptyDefinitions(t *testing.T) {
|
||||
invalidRoot := conferencePluginDescriptor()
|
||||
invalidRoot.CLI.Command = "Invalid Root"
|
||||
@@ -507,7 +544,8 @@ func TestPluginConstraintGroupAndRootHelpers(t *testing.T) {
|
||||
mergePluginRoot(nil, root)
|
||||
mergePluginRoot(root, nil)
|
||||
destination := &cobra.Command{Use: "plugin", Aliases: []string{"one"}}
|
||||
source := &cobra.Command{Use: "plugin", Aliases: []string{"one", "two"}}
|
||||
source := cobracmd.NewGroupCommand("plugin", "plugin")
|
||||
source.Aliases = []string{"one", "two"}
|
||||
source.AddCommand(&cobra.Command{Use: "leaf"})
|
||||
mergePluginRoot(destination, source)
|
||||
if !reflect.DeepEqual(destination.Aliases, []string{"one", "two"}) || requireOptionalPluginChild(destination, "leaf") == nil {
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"bytes"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -55,6 +56,44 @@ func TestCrossPlatformCoverageLeadingPersistentFlagVariantsReachTheRealCommand(t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFuzzyRootBooleanBetweenGroupAndLeafKeepsLeafPreParse(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{
|
||||
"aisearch", "--query", "Alice", "--yess", "enterprise",
|
||||
"--queries", "fixture", "--content-types", "document", "--time_range", "本周", "--help",
|
||||
}
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws aisearch enterprise" ||
|
||||
!slices.Contains(ctx.Args, "--yes") || !slices.Contains(ctx.Args, "--types") || !slices.Contains(ctx.Args, "--time-range") {
|
||||
t.Fatalf("group-middle fuzzy flag skipped leaf PreParse: context=%#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("corrected group-middle persistent flag failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProtectedFlagChildNameValueStaysOnOwningCommand(t *testing.T) {
|
||||
for _, args := range [][]string{
|
||||
{"aisearch", "--types", "enterprise"},
|
||||
{"aisearch", "--types", "false", "enterprise"},
|
||||
{"aisearch", "--types=false", "enterprise"},
|
||||
} {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws aisearch" || !ctx.IsFlagProtected("types") || !slices.Equal(ctx.Args, args) {
|
||||
t.Fatalf("protected child-name value selected wrong command: args=%v context=%#v", args, ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePreParseConflictHonorsErrorPresentationFlags(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
args := []string{
|
||||
@@ -105,6 +144,7 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
args []string
|
||||
wantReason string
|
||||
wantCommand string
|
||||
wantHint string
|
||||
}{
|
||||
{
|
||||
name: "unknown shortcut",
|
||||
@@ -118,6 +158,13 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws dev app",
|
||||
},
|
||||
{
|
||||
name: "unknown aisearch subcommand before protected flag",
|
||||
args: []string{"aisearch", "--query", "Alice", "enterprize", "--types", "enterprise", "--format", "json"},
|
||||
wantReason: "unknown_subcommand",
|
||||
wantCommand: "dws aisearch",
|
||||
wantHint: "dws aisearch enterprise",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
@@ -135,6 +182,9 @@ func TestCrossPlatformCoverageCommandResolutionPrecedesFlagErrorsOnProductionTre
|
||||
if structured.Reason != test.wantReason || structured.ExitCode() != 3 {
|
||||
t.Fatalf("structured error = %#v", structured)
|
||||
}
|
||||
if test.wantHint != "" && !strings.Contains(structured.Hint, test.wantHint) {
|
||||
t.Fatalf("hint = %q, want %q", structured.Hint, test.wantHint)
|
||||
}
|
||||
if len(structured.AvailableFlags) != 0 || strings.Contains(structured.Message, "unknown flag") {
|
||||
t.Fatalf("command error leaked flag classification: %#v", structured)
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -653,8 +654,11 @@ func TestAuthCommandDoesNotExposeSwitch(t *testing.T) {
|
||||
if err == nil {
|
||||
t.Fatalf("auth switch succeeded, want unknown command error\noutput:\n%s", out.String())
|
||||
}
|
||||
if !strings.Contains(err.Error(), `unknown command "switch" for "dws auth"`) {
|
||||
t.Fatalf("error = %v, want auth switch unknown command", err)
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) || structured.Reason != "unknown_subcommand" ||
|
||||
structured.Message != `unknown subcommand "switch" for "dws auth"` ||
|
||||
structured.Hint != "Run 'dws auth --help' for the full list" {
|
||||
t.Fatalf("error = %#v, want bounded auth subcommand guidance", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-10
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
@@ -924,6 +925,11 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
|
||||
return nil
|
||||
},
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
|
||||
bindPersistentFlags(root, flags)
|
||||
|
||||
@@ -935,25 +941,42 @@ func newRootCommandWithMode(rootCtx context.Context, engine *pipeline.Engine, lo
|
||||
patCaller := newRecordingToolCaller(newToolCallerAdapter(runner, flags))
|
||||
mcpCmd.AddCommand(newMCPURLGroup(patCaller))
|
||||
|
||||
navigationGroup := func(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
hybridGroup := func(command *cobra.Command) *cobra.Command {
|
||||
corecmd.ApplyGroupPolicy(command, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return command
|
||||
}
|
||||
|
||||
utilityCommands := []*cobra.Command{
|
||||
newAuthCommand(patCaller),
|
||||
newProfileCommand(),
|
||||
navigationGroup(newAuthCommand(patCaller)),
|
||||
navigationGroup(newProfileCommand()),
|
||||
newAPICommand(flags),
|
||||
newSkillCommand(),
|
||||
newCacheCommand(),
|
||||
navigationGroup(newSkillCommand()),
|
||||
hybridGroup(newCacheCommand()),
|
||||
newCatalogCommand(),
|
||||
newConfigCommand(),
|
||||
navigationGroup(newConfigCommand()),
|
||||
newDoctorCommand(),
|
||||
newRecoveryCommand(),
|
||||
newEventCommand(flags),
|
||||
newAuditCommand(),
|
||||
hybridGroup(newRecoveryCommand()),
|
||||
navigationGroup(newEventCommand(flags)),
|
||||
navigationGroup(newAuditCommand()),
|
||||
newCompletionCommand(root),
|
||||
newUpgradeCommand(),
|
||||
newVersionCommand(),
|
||||
newPluginCommand(),
|
||||
usage.NewShortcutCommand(),
|
||||
navigationGroup(usage.NewShortcutCommand()),
|
||||
schemaCmd,
|
||||
mcpCmd,
|
||||
navigationGroup(mcpCmd),
|
||||
}
|
||||
root.AddCommand(utilityCommands...)
|
||||
|
||||
|
||||
@@ -145,8 +145,10 @@ func TestRootKeepsMainBranchChatCompatibilityCommands(t *testing.T) {
|
||||
command.SilenceUsage = true
|
||||
command.SetArgs(tc.args)
|
||||
err := command.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "ambiguous command") || !strings.Contains(err.Error(), tc.hint) {
|
||||
t.Fatalf("dws %s error = %v, want migration hint %q", strings.Join(tc.args, " "), err, tc.hint)
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Category != apperrors.CategoryValidation ||
|
||||
structured.Reason != "unknown_subcommand" || !strings.Contains(structured.Hint, tc.hint) {
|
||||
t.Fatalf("dws %s error = %#v, want migration hint %q", strings.Join(tc.args, " "), structured, tc.hint)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -740,6 +740,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"mcp_tool_error",
|
||||
"MCP tool returned a business error; check tool parameters and refer to skill documentation.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(mcpErr, "mcp_tool_error"), invocation, callResult.Content, diag)
|
||||
@@ -765,6 +766,7 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string,
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
invocation.CanonicalProduct,
|
||||
invocation.Tool,
|
||||
diag,
|
||||
)
|
||||
logBusinessError(r.transport.FileLogger, serverFailureReason(classifiedErr, "business_error"), invocation, callResult.Content, diag)
|
||||
|
||||
@@ -72,6 +72,9 @@ func missingChatCatalogCoveragePaths() []string {
|
||||
"chat clear-messages",
|
||||
"chat clear-red-point",
|
||||
"chat data-auth cross-org",
|
||||
"chat emotion favorite",
|
||||
"chat emotion list",
|
||||
"chat emotion send",
|
||||
"chat group audit-join-validation",
|
||||
"chat group list-all",
|
||||
"chat group list-join-validations",
|
||||
|
||||
@@ -136,6 +136,53 @@ func TestCrossPlatformCoverageOAAttachmentDeliveredSchemaMatchesExecutableHelp(t
|
||||
}
|
||||
}
|
||||
|
||||
// TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema 验证合并后的
|
||||
// upload 命令以 composite 接口模式交付:它内部串联 init/commit 两个 RPC 与本地 HTTP PUT,
|
||||
// 无法绑定单一 interface_ref,因此不进入上面按 mcp 模式断言的表驱动用例。
|
||||
func TestCrossPlatformCoverageOAAttachmentUploadDeliversCompositeSchema(t *testing.T) {
|
||||
snapshot := fullSchemaSnapshotForTest(t)
|
||||
tool := snapshot.Tools["oa.attachment_upload"]
|
||||
if tool == nil {
|
||||
t.Fatal("oa.attachment_upload is missing from final Schema")
|
||||
}
|
||||
if got := schemaContractString(tool["primary_cli_path"]); got != "oa approval attachment upload" {
|
||||
t.Fatalf("primary_cli_path = %q, want oa approval attachment upload", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("interface_mode = %q, want composite", got)
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Fatalf("availability = %q, want available", got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Fatal("composite upload command must document an interface reason")
|
||||
}
|
||||
if got := schemaContractString(tool["effect"]); got != "write" {
|
||||
t.Fatalf("effect = %q, want write", got)
|
||||
}
|
||||
if got := schemaContractString(tool["risk"]); got != "low" {
|
||||
t.Fatalf("risk = %q, want low", got)
|
||||
}
|
||||
if got := schemaContractString(tool["confirmation"]); got != "not_required" {
|
||||
t.Fatalf("confirmation = %q, want not_required", got)
|
||||
}
|
||||
parameters := schemaContractMap(tool["parameters"])
|
||||
for _, flag := range []string{"file", "file-name", "md5"} {
|
||||
if parameters[flag] == nil {
|
||||
t.Fatalf("upload --%s is missing from final Schema", flag)
|
||||
}
|
||||
}
|
||||
if required, _ := parameters["file"]["required"].(bool); !required {
|
||||
t.Fatalf("upload --file required = %#v, want true", parameters["file"]["required"])
|
||||
}
|
||||
result := schemaContractMap(tool["result"])
|
||||
dataSchema := schemaContractMap(result["data_schema"])
|
||||
properties := schemaContractMap(dataSchema["properties"])
|
||||
if properties["fileId"] == nil {
|
||||
t.Fatal("upload Result data_schema is missing fileId")
|
||||
}
|
||||
}
|
||||
|
||||
func oaAttachmentResultContract(t *testing.T, tool map[string]any, resultType string, fields map[string]string, sensitivePaths []string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := tool["result"].(map[string]any)
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSheetFloatImageLocalFileFinalSchema(t *testing.T) {
|
||||
payload := schemaContractPayloadForBoundCanonicals(t, NewRootCommand(),
|
||||
"sheet.create_float_image",
|
||||
"sheet.update_float_image",
|
||||
)
|
||||
|
||||
create := payload.Tools["sheet.create_float_image"]
|
||||
if create == nil {
|
||||
t.Fatal("missing sheet.create_float_image")
|
||||
}
|
||||
if create["interface_mode"] != "mcp" {
|
||||
t.Fatalf("create interface mode = %#v", create["interface_mode"])
|
||||
}
|
||||
createRef, _ := create["interface_ref"].(map[string]any)
|
||||
if createRef["product_id"] != "sheet" || createRef["rpc_name"] != "create_float_image" {
|
||||
t.Fatalf("create interface ref = %#v", createRef)
|
||||
}
|
||||
createDryRun, _ := create["dry_run"].(map[string]any)
|
||||
if createDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("create dry-run = %#v", createDryRun)
|
||||
}
|
||||
if remoteReads, exists := createDryRun["remote_reads"]; exists && remoteReads != false {
|
||||
t.Fatalf("create dry-run remote_reads = %#v", remoteReads)
|
||||
}
|
||||
createParameters, _ := create["parameters"].(map[string]any)
|
||||
file, _ := createParameters["file"].(map[string]any)
|
||||
src, _ := createParameters["src"].(map[string]any)
|
||||
if file["required"] != false || file["required_when"] != "exactly one of --file or --src must be provided" {
|
||||
t.Fatalf("create --file metadata = %#v", file)
|
||||
}
|
||||
if schemaContractString(file["property"]) != "" {
|
||||
t.Fatalf("create --file leaked an RPC property: %#v", file["property"])
|
||||
}
|
||||
if src["required"] != false || schemaContractString(src["required_when"]) != "" || src["property"] != "src" {
|
||||
t.Fatalf("create --src compatibility metadata = %#v", src)
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, create, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, create, "require_one_of", []string{"file", "src"})
|
||||
|
||||
update := payload.Tools["sheet.update_float_image"]
|
||||
if update == nil {
|
||||
t.Fatal("missing sheet.update_float_image")
|
||||
}
|
||||
updateDryRun, _ := update["dry_run"].(map[string]any)
|
||||
if update["interface_mode"] != "mcp" || updateDryRun["preview_kind"] != "request" {
|
||||
t.Fatalf("update interface/dry-run = %#v/%#v", update["interface_mode"], updateDryRun)
|
||||
}
|
||||
updateParameters, _ := update["parameters"].(map[string]any)
|
||||
updateFile, _ := updateParameters["file"].(map[string]any)
|
||||
if schemaContractString(updateFile["property"]) != "" {
|
||||
t.Fatalf("update --file leaked an RPC property: %#v", updateParameters["file"])
|
||||
}
|
||||
assertSchemaContractConstraintGroup(t, update, "mutually_exclusive", []string{"file", "src"})
|
||||
assertSchemaContractConstraintGroup(t, update, "require_one_of", []string{"file", "src", "range", "width", "height", "offset-x", "offset-y"})
|
||||
|
||||
root := NewRootCommand()
|
||||
for _, cliPath := range []string{"sheet create-float-image", "sheet update-float-image"} {
|
||||
command := exactCommandForTest(root, cliPath)
|
||||
if command == nil || command.Flags().Lookup("file") == nil {
|
||||
t.Fatalf("%s has no executable --file flag", cliPath)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
publicShortcutCount = 422
|
||||
publicShortcutCount = 436
|
||||
// schemaPublishedShortcutCount counts every delivered *.shortcut_* tool,
|
||||
// including the hidden historical minutes.shortcut_minutes_search contract.
|
||||
schemaPublishedShortcutCount = 447
|
||||
// including reviewed hidden compatibility and unavailable contracts.
|
||||
schemaPublishedShortcutCount = 493
|
||||
// publiclyDeliveredShortcutCount is the public-catalog subset of that surface.
|
||||
publiclyDeliveredShortcutCount = 422
|
||||
publiclyDeliveredShortcutCount = 436
|
||||
)
|
||||
|
||||
func TestDeliverySchemaCoversOrExactlyExcludesEveryPublicShortcutContract(t *testing.T) {
|
||||
@@ -114,7 +114,7 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
|
||||
product := executeShortcutSchemaQuery(t, "chat")
|
||||
productPayload, _ := product["product"].(map[string]any)
|
||||
if got, want := int(product["count"].(float64)), 217; got != want {
|
||||
if got, want := int(product["count"].(float64)), 220; got != want {
|
||||
t.Fatalf("schema chat count = %d, want %d", got, want)
|
||||
}
|
||||
summaries := schemaContractObjectSlice(productPayload["tools"])
|
||||
@@ -140,6 +140,57 @@ func TestDeliveryShortcutProgressiveQueriesReturnCompleteContracts(t *testing.T)
|
||||
assertChatCatalogCompleteLeafContracts(t)
|
||||
}
|
||||
|
||||
func TestChatPersonalEmotionSchemaDeclaresUnpinnedIMAdapter(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
cliPath string
|
||||
params map[string]string
|
||||
}{
|
||||
{
|
||||
cliPath: "chat emotion list",
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion send",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"emotion-id": "emotionId",
|
||||
"group": "openConversationId",
|
||||
"open-dingtalk-id": "receiverOpenDingTalkId",
|
||||
"idempotency-key": "uuid",
|
||||
},
|
||||
},
|
||||
{
|
||||
cliPath: "chat emotion favorite",
|
||||
params: map[string]string{
|
||||
"media-id": "mediaId",
|
||||
"name": "name",
|
||||
"source-conversation-id": "sourceConversationId",
|
||||
"source-message-id": "sourceMessageId",
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.cliPath, func(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", tc.cliPath)
|
||||
if got := schemaContractString(leaf["interface_mode"]); got != "composite" {
|
||||
t.Fatalf("%s interface_mode = %q, want composite", tc.cliPath, got)
|
||||
}
|
||||
reason := schemaContractString(leaf["interface_reason"])
|
||||
if !strings.Contains(reason, "Reviewed unpinned remote adapter") {
|
||||
t.Fatalf("%s interface_reason = %q", tc.cliPath, reason)
|
||||
}
|
||||
parameters := schemaContractMap(leaf["parameters"])
|
||||
for name, want := range tc.params {
|
||||
parameter := parameters[name]
|
||||
if parameter == nil {
|
||||
t.Fatalf("%s missing --%s parameter: %#v", tc.cliPath, name, parameters)
|
||||
}
|
||||
if got := schemaContractString(parameter["property"]); got != want {
|
||||
t.Fatalf("%s --%s property = %q, want %q", tc.cliPath, name, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAITableTableBootstrapPublishesResultContract(t *testing.T) {
|
||||
leaf := executeShortcutSchemaQuery(t, "--cli-path", "aitable +table-bootstrap")
|
||||
result, _ := leaf["result"].(map[string]any)
|
||||
@@ -220,6 +271,60 @@ func TestAllShortcutsWikiSchemaExamplesIncludeRequiredParameters(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllShortcutsAITableDatasourceExamplesSourceConfigHasRequiredMembers(t *testing.T) {
|
||||
tools := deliverySchemaAllToolsForHelpFlagTest(t, NewRootCommand())
|
||||
requiredSourceConfigMembers := []string{"processCode", "name", "iconUrl", "url"}
|
||||
checked := 0
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service != "aitable" || declared.UserDefined || !shortcut.InPublicCatalog(declared.Service, declared.Command) {
|
||||
continue
|
||||
}
|
||||
if !strings.HasPrefix(declared.Command, "+datasource-") {
|
||||
continue
|
||||
}
|
||||
if declared.Command != "+datasource-create" && declared.Command != "+datasource-update" && declared.Command != "+datasource-get-fields" {
|
||||
continue
|
||||
}
|
||||
checked++
|
||||
canonical := shortcutSchemaCanonical(declared)
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Fatalf("delivery schema --all is missing %s", canonical)
|
||||
}
|
||||
examples := schemaContractStringSlice(tool["examples"])
|
||||
if len(examples) == 0 {
|
||||
t.Fatalf("%s has no delivered examples", canonical)
|
||||
}
|
||||
for _, example := range examples {
|
||||
if !strings.Contains(example, "--source-config") {
|
||||
continue
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(example)
|
||||
if err != nil {
|
||||
t.Fatalf("%s example %q is not valid argv: %v", canonical, example, err)
|
||||
}
|
||||
sourceConfig := schemaExampleFlagValue(argv, "source-config")
|
||||
if sourceConfig == "" {
|
||||
t.Errorf("%s example %q contains --source-config but has no value", canonical, example)
|
||||
continue
|
||||
}
|
||||
var cfg map[string]any
|
||||
if err := json.Unmarshal([]byte(sourceConfig), &cfg); err != nil {
|
||||
t.Errorf("%s example %q has invalid source-config JSON: %v", canonical, example, err)
|
||||
continue
|
||||
}
|
||||
for _, member := range requiredSourceConfigMembers {
|
||||
if _, ok := cfg[member]; !ok {
|
||||
t.Errorf("%s example %q source-config is missing required member %q", canonical, example, member)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if checked != 3 {
|
||||
t.Fatalf("checked aitable datasource source-config examples = %d, want 3", checked)
|
||||
}
|
||||
}
|
||||
|
||||
func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
for _, argument := range argv {
|
||||
for _, name := range names {
|
||||
@@ -231,6 +336,25 @@ func schemaExampleHasLongFlag(argv []string, names ...string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func schemaExampleFlagValue(argv []string, name string) string {
|
||||
prefix := "--" + name + "="
|
||||
for _, argument := range argv {
|
||||
if argument == "--"+name {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(argument, prefix) {
|
||||
return strings.TrimPrefix(argument, prefix)
|
||||
}
|
||||
}
|
||||
// Value may be in the next argv entry: `--flag value` form.
|
||||
for i := 0; i < len(argv)-1; i++ {
|
||||
if argv[i] == "--"+name {
|
||||
return argv[i+1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func assertSchemaSummarySafety(
|
||||
t testing.TB,
|
||||
summaries map[string]map[string]any,
|
||||
|
||||
@@ -20,18 +20,50 @@ import (
|
||||
)
|
||||
|
||||
type serverFailureClass struct {
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
message string
|
||||
reason string
|
||||
origin string
|
||||
stage string
|
||||
hint string
|
||||
actions []string
|
||||
operation string
|
||||
retryable *bool
|
||||
}
|
||||
|
||||
func classifyServerFailure(message string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
func classifyServerFailure(message, serverKey, tool string, diag apperrors.ServerDiagnostics) (serverFailureClass, bool) {
|
||||
code := strings.ToUpper(strings.TrimSpace(diag.ServerErrorCode))
|
||||
detail := strings.ToLower(strings.TrimSpace(diag.TechnicalDetail))
|
||||
text := strings.ToLower(strings.TrimSpace(message))
|
||||
combined := text + " " + detail
|
||||
|
||||
if code == "999" &&
|
||||
(strings.Contains(combined, "nullpointerexception") || strings.Contains(combined, "system error")) {
|
||||
classified := serverFailureClass{
|
||||
message: message,
|
||||
reason: "upstream_internal_error",
|
||||
origin: "dingtalk_api",
|
||||
stage: "upstream_execution",
|
||||
hint: "上游服务发生内部异常;请保留 Trace ID 和 Server Code,确认操作结果后再决定是否重试。",
|
||||
actions: []string{
|
||||
"检查目标资源的当前状态,确认本次操作是否已经生效",
|
||||
"状态未确认前不要直接重试写操作",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
},
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(serverKey), "todo") &&
|
||||
strings.EqualFold(strings.TrimSpace(tool), "create_personal_todo") {
|
||||
retryable := false
|
||||
classified.operation = "todo/create_personal_todo"
|
||||
classified.retryable = &retryable
|
||||
classified.hint = "待办服务发生内部异常,创建结果未知;请先查询是否已创建相同待办,再决定是否重试。"
|
||||
classified.actions = []string{
|
||||
"查询近期由自己创建的待办,核对标题、执行人和截止时间",
|
||||
"确认没有创建成功后再重新提交",
|
||||
"持续失败时携带 Trace ID 和 Server Code 联系服务端排查",
|
||||
}
|
||||
}
|
||||
return classified, true
|
||||
}
|
||||
|
||||
if code == "NETWORK_ERROR" ||
|
||||
strings.Contains(detail, "statuscode.unavailable") ||
|
||||
@@ -74,6 +106,7 @@ func newServerFailureAPIError(
|
||||
fallbackReason string,
|
||||
fallbackHint string,
|
||||
serverKey string,
|
||||
tool string,
|
||||
diag apperrors.ServerDiagnostics,
|
||||
) error {
|
||||
opts := []apperrors.Option{
|
||||
@@ -84,7 +117,7 @@ func newServerFailureAPIError(
|
||||
apperrors.WithActions("运行 dws doctor 检查登录态、网络和本地环境;持续失败时保留 Trace ID 和 Server Code"),
|
||||
apperrors.WithServerDiag(diag),
|
||||
}
|
||||
if classified, ok := classifyServerFailure(message, diag); ok {
|
||||
if classified, ok := classifyServerFailure(message, serverKey, tool, diag); ok {
|
||||
message = classified.message
|
||||
opts = append(opts,
|
||||
apperrors.WithReason(classified.reason),
|
||||
@@ -93,6 +126,12 @@ func newServerFailureAPIError(
|
||||
apperrors.WithHint(classified.hint),
|
||||
apperrors.WithActions(classified.actions...),
|
||||
)
|
||||
if classified.operation != "" {
|
||||
opts = append(opts, apperrors.WithOperation(classified.operation))
|
||||
}
|
||||
if classified.retryable != nil {
|
||||
opts = append(opts, apperrors.WithRetryable(*classified.retryable))
|
||||
}
|
||||
}
|
||||
return apperrors.NewAPI(message, opts...)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestCrossPlatformCoverageServerFailureClassifierBackendMetadataUnavailable(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-local",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -66,6 +67,7 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"chat",
|
||||
"send_message",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "1001"},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -80,12 +82,74 @@ func TestCrossPlatformCoverageServerFailureClassifierRequiredConversationID(t *t
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
serverSaysRetryable := true
|
||||
err := newServerFailureAPIError(
|
||||
"[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
"business_error",
|
||||
"The API returned a business-level error. Check required parameters and values.",
|
||||
"todo",
|
||||
"create_personal_todo",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-todo-create",
|
||||
ServerErrorCode: "999",
|
||||
ServerRetryable: &serverSaysRetryable,
|
||||
},
|
||||
)
|
||||
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("error = %T, want *errors.Error", err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Origin != "dingtalk_api" || typed.FailureStage != "upstream_execution" {
|
||||
t.Fatalf("classification = reason %q origin %q stage %q", typed.Reason, typed.Origin, typed.FailureStage)
|
||||
}
|
||||
if typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("operation = %q, want todo/create_personal_todo", typed.Operation)
|
||||
}
|
||||
if typed.ExecutionStarted != nil {
|
||||
t.Fatalf("execution_started = %v, want unknown", typed.ExecutionStarted)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable {
|
||||
t.Fatalf("retryability = (%v, %v), want explicit false", typed.RetryableSet, typed.Retryable)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-create" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(typed.Hint), "parameter") || !strings.Contains(typed.Hint, "创建结果未知") {
|
||||
t.Fatalf("hint = %q", typed.Hint)
|
||||
}
|
||||
for _, action := range typed.Actions {
|
||||
if strings.Contains(action, "dws doctor") || strings.Contains(action, "登录") || strings.Contains(action, "网络") {
|
||||
t.Fatalf("misleading action = %q", action)
|
||||
}
|
||||
}
|
||||
|
||||
payload := multiProfileErrorPayload(err)
|
||||
for key, want := range map[string]any{
|
||||
"reason": "upstream_internal_error",
|
||||
"origin": "dingtalk_api",
|
||||
"stage": "upstream_execution",
|
||||
"retryable": false,
|
||||
"trace_id": "trace-todo-create",
|
||||
"server_error_code": "999",
|
||||
} {
|
||||
if got := payload[key]; got != want {
|
||||
t.Errorf("payload[%q] = %#v, want %#v", key, got, want)
|
||||
}
|
||||
}
|
||||
if _, ok := payload["execution_started"]; ok {
|
||||
t.Fatalf("payload must keep execution_started unknown: %#v", payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageServerFailureClassifierUnknownFallsBack(t *testing.T) {
|
||||
err := newServerFailureAPIError(
|
||||
"business error: success=false",
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{},
|
||||
)
|
||||
var typed *apperrors.Error
|
||||
@@ -106,6 +170,7 @@ func TestCrossPlatformCoverageServerFailureReasonUsesTypedClassification(t *test
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{ServerErrorCode: "NETWORK_ERROR"},
|
||||
)
|
||||
if got := serverFailureReason(err, "business_error"); got != "backend_dependency_unavailable" {
|
||||
@@ -123,6 +188,7 @@ func TestCrossPlatformCoverageMultiProfileErrorPayloadPreservesFailureSemantics(
|
||||
"business_error",
|
||||
"check parameters",
|
||||
"im",
|
||||
"list_conversations",
|
||||
apperrors.ServerDiagnostics{
|
||||
TraceID: "trace-multi",
|
||||
ServerErrorCode: "NETWORK_ERROR",
|
||||
@@ -224,3 +290,58 @@ func TestCrossPlatformCoverageExecuteInvocationClassifiesObservedMCPMetadataFail
|
||||
t.Fatalf("execution_started must remain unknown: %v", typed.ExecutionStarted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageExecuteInvocationClassifiesTodoCreateUpstreamInternalError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var request struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"jsonrpc": "2.0",
|
||||
"id": request.ID,
|
||||
"result": map[string]any{
|
||||
"structuredContent": map[string]any{
|
||||
"success": false,
|
||||
"code": "999",
|
||||
"trace_id": "trace-todo-replay",
|
||||
"errorMsg": "[UNCLASSIFIED] system error: java.lang.NullPointerException (operation: todo/create_personal_todo)",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := transport.NewClient(server.Client())
|
||||
client.TrustedDomains = []string{strings.TrimPrefix(server.URL, "http://")}
|
||||
runner := &runtimeRunner{
|
||||
transport: client,
|
||||
globalFlags: &GlobalFlags{Token: "local-test-token"},
|
||||
}
|
||||
_, err := runner.executeInvocation(context.Background(), server.URL, executor.Invocation{
|
||||
CanonicalProduct: "todo",
|
||||
Tool: "create_personal_todo",
|
||||
CanonicalPath: "todo.create_personal_todo",
|
||||
Params: map[string]any{
|
||||
"PersonalTodoCreateVO": map[string]any{
|
||||
"subject": "fixture",
|
||||
"executorIds": []string{"user-1"},
|
||||
},
|
||||
},
|
||||
})
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("executeInvocation() error = %T %v, want typed API error", err, err)
|
||||
}
|
||||
if typed.Reason != "upstream_internal_error" || typed.Operation != "todo/create_personal_todo" {
|
||||
t.Fatalf("classification = reason %q operation %q", typed.Reason, typed.Operation)
|
||||
}
|
||||
if !typed.RetryableSet || typed.Retryable || typed.ExecutionStarted != nil {
|
||||
t.Fatalf("failure semantics = retryable(%v,%v) execution_started=%v", typed.RetryableSet, typed.Retryable, typed.ExecutionStarted)
|
||||
}
|
||||
if typed.ServerDiag.TraceID != "trace-todo-replay" || typed.ServerDiag.ServerErrorCode != "999" {
|
||||
t.Fatalf("diagnostics = %#v", typed.ServerDiag)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func assertWhiteboardPublicShortcutsStayAvailableInSchema(t *testing.T, root *cobra.Command, tools map[string]map[string]any) {
|
||||
t.Helper()
|
||||
for canonical, command := range map[string]string{
|
||||
"whiteboard.shortcut_query": "+query",
|
||||
"whiteboard.shortcut_update": "+update",
|
||||
} {
|
||||
leaf, _, err := root.Find([]string{"whiteboard", command})
|
||||
if err != nil || leaf == nil || leaf.Name() != command {
|
||||
t.Errorf("find whiteboard %s: leaf=%v err=%v", command, leaf, err)
|
||||
} else if leaf.Hidden || !leaf.Runnable() {
|
||||
t.Errorf("whiteboard %s hidden/runnable=%v/%v, want false/true", command, leaf.Hidden, leaf.Runnable())
|
||||
}
|
||||
tool := tools[canonical]
|
||||
if tool == nil {
|
||||
t.Errorf("public %s missing from delivery Schema surface", canonical)
|
||||
continue
|
||||
}
|
||||
if got := schemaContractString(tool["availability"]); got != "available" {
|
||||
t.Errorf("%s availability=%q, want available", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_mode"]); got != "composite" {
|
||||
t.Errorf("%s interface_mode=%q, want composite", canonical, got)
|
||||
}
|
||||
if got := schemaContractString(tool["interface_reason"]); got == "" {
|
||||
t.Errorf("%s missing composite adapter reason", canonical)
|
||||
}
|
||||
if tool["interface_ref"] != nil {
|
||||
t.Errorf("%s composite interface_ref=%#v, want nil", canonical, tool["interface_ref"])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -192,6 +192,7 @@ func (p *OAuthProvider) refreshWithRefreshToken(ctx context.Context, data *Token
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
@@ -239,6 +240,7 @@ func (p *OAuthProvider) refreshViaMCP(ctx context.Context, data *TokenData) (*To
|
||||
updated.CorpID = data.CorpID
|
||||
updated.UserID = data.UserID
|
||||
updated.UserName = data.UserName
|
||||
updated.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
if updated.CorpName == "" {
|
||||
updated.CorpName = data.CorpName
|
||||
}
|
||||
|
||||
@@ -811,7 +811,207 @@ func (p *OAuthProvider) lockedRefresh(ctx context.Context) (*TokenData, error) {
|
||||
if p.logger != nil {
|
||||
p.logger.Debug("refreshing token (dual-locked)")
|
||||
}
|
||||
return oauthRefreshToken(p, ctx, data)
|
||||
refreshed, rErr := oauthRefreshToken(p, ctx, data)
|
||||
if rErr == nil || !isRefreshTokenRejected(rErr) {
|
||||
return refreshed, rErr
|
||||
}
|
||||
// A stale identity slot can survive an older organization-only refresh.
|
||||
// Retry once with the same-corp organization mirror while holding the
|
||||
// existing dual lock; the fallback marks the publication so the rotated
|
||||
// credential is written back into the mirror slot it consumed.
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.triggered",
|
||||
"corp_id", strings.TrimSpace(data.CorpID),
|
||||
"user_id", strings.TrimSpace(data.UserID),
|
||||
"error", rErr,
|
||||
)
|
||||
fallback, fErr := p.refreshFromOrgSlot(ctx, data)
|
||||
if fErr != nil {
|
||||
logging.AuthDebug("auth.refresh.fallback.unavailable", "error", fErr)
|
||||
// The organization mirror may be absent for long-lived local logins
|
||||
// that predate mirror publication. Recover from the legacy global
|
||||
// slot before giving up.
|
||||
if recovered, recoverErr := p.recoverRefreshFromLegacyGlobalSlot(ctx, data, rErr); recoverErr == nil {
|
||||
return recovered, nil
|
||||
}
|
||||
return nil, rErr
|
||||
}
|
||||
if p.logger != nil {
|
||||
p.logger.Warn(i18n.T("当前身份的 refresh_token 已失效,已从组织镜像 token 恢复登录态"))
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
// refreshFromOrgSlot retries a rejected refresh with the token mirrored in
|
||||
// the organization slot. The mirror must match the current corp, be valid,
|
||||
// and differ from the rejected token. When both slots carry user identities,
|
||||
// they must agree; legacy mirrors with an empty UserID are backfilled from the
|
||||
// current identity before refresh.
|
||||
func (p *OAuthProvider) refreshFromOrgSlot(ctx context.Context, current *TokenData) (*TokenData, error) {
|
||||
if current == nil {
|
||||
return nil, fmt.Errorf("no current token data")
|
||||
}
|
||||
corpID := strings.TrimSpace(current.CorpID)
|
||||
if corpID == "" {
|
||||
return nil, fmt.Errorf("current token has no corpId")
|
||||
}
|
||||
orgData, err := tokenLoadKeychainForCorpID(corpID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orgData == nil {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
if strings.TrimSpace(orgData.CorpID) != corpID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q contains token for corpId %q; refusing refresh fallback", corpID, orgData.CorpID)
|
||||
}
|
||||
if !orgData.IsRefreshTokenValid() {
|
||||
return nil, fmt.Errorf("organization mirror refresh_token 已过期")
|
||||
}
|
||||
if orgData.RefreshToken == current.RefreshToken {
|
||||
return nil, fmt.Errorf("organization mirror holds the same rejected refresh_token")
|
||||
}
|
||||
currentUserID := strings.TrimSpace(current.UserID)
|
||||
orgUserID := strings.TrimSpace(orgData.UserID)
|
||||
if currentUserID != "" && orgUserID != "" && orgUserID != currentUserID {
|
||||
return nil, fmt.Errorf("organization token mirror for corpId %q belongs to userId %q; refusing refresh fallback for userId %q", corpID, orgData.UserID, current.UserID)
|
||||
}
|
||||
if orgUserID == "" {
|
||||
orgData.UserID = current.UserID
|
||||
orgData.UserName = current.UserName
|
||||
}
|
||||
// The refresh below consumes the mirror's refresh_token. Mark the
|
||||
// publication so persistence writes the rotated credential back into the
|
||||
// organization slot even under an explicit runtime selector whose plan
|
||||
// would otherwise skip it (for example a preserved unresolved sibling).
|
||||
orgData.RepairOrganizationMirror = true
|
||||
refreshed, err := oauthRefreshToken(p, ctx, orgData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
logging.AuthDebug(
|
||||
"auth.refresh.fallback.success",
|
||||
"corp_id", corpID,
|
||||
"new_at_expires_at", refreshed.ExpiresAt.Format(time.RFC3339),
|
||||
)
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func (p *OAuthProvider) recoverRefreshFromLegacyGlobalSlot(ctx context.Context, selected *TokenData, refreshErr error) (*TokenData, error) {
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
if !errors.As(refreshErr, &exchangeErr) || !exchangeErr.requiresReauthorization() {
|
||||
return nil, refreshErr
|
||||
}
|
||||
if selected == nil {
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.triggered",
|
||||
"corp_id", strings.TrimSpace(selected.CorpID),
|
||||
"user_id", strings.TrimSpace(selected.UserID),
|
||||
"refresh_error_code", exchangeErr.Code,
|
||||
)
|
||||
legacy, loadErr := tokenLoadKeychain()
|
||||
if loadErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "error", loadErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
if legacy == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "load_legacy", "reason", "empty_legacy")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if !legacyGlobalRefreshCandidateMatches(p.configDir, selected, legacy) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed",
|
||||
"step", "candidate_mismatch",
|
||||
"legacy_corp_id", strings.TrimSpace(legacy.CorpID),
|
||||
"legacy_user_id", strings.TrimSpace(legacy.UserID),
|
||||
)
|
||||
return nil, refreshErr
|
||||
}
|
||||
recovered := *legacy
|
||||
if strings.TrimSpace(recovered.UserID) == "" {
|
||||
recovered.UserID = strings.TrimSpace(selected.UserID)
|
||||
}
|
||||
if strings.TrimSpace(recovered.UserName) == "" {
|
||||
recovered.UserName = strings.TrimSpace(selected.UserName)
|
||||
}
|
||||
if recovered.IsAccessTokenValid() {
|
||||
if err := oauthSaveTokenLocked(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "save", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "valid_access_token")
|
||||
return &recovered, nil
|
||||
}
|
||||
if !recovered.IsRefreshTokenValid() {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh_expired")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if strings.TrimSpace(recovered.RefreshToken) == strings.TrimSpace(selected.RefreshToken) {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "same_refresh_token")
|
||||
return nil, refreshErr
|
||||
}
|
||||
if err := preflightTokenRefreshPersistence(p.configDir, &recovered); err != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "preflight", "error", err)
|
||||
return nil, refreshErr
|
||||
}
|
||||
refreshed, recoverErr := oauthRefreshToken(p, ctx, &recovered)
|
||||
if recoverErr != nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.failed", "step", "refresh", "error", recoverErr)
|
||||
return nil, refreshErr
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.success", "via", "refresh")
|
||||
return refreshed, nil
|
||||
}
|
||||
|
||||
func legacyGlobalRefreshCandidateMatches(configDir string, selected, legacy *TokenData) bool {
|
||||
if selected == nil || legacy == nil {
|
||||
return false
|
||||
}
|
||||
selectedCorpID := strings.TrimSpace(selected.CorpID)
|
||||
legacyCorpID := strings.TrimSpace(legacy.CorpID)
|
||||
if selectedCorpID == "" || legacyCorpID != selectedCorpID {
|
||||
return false
|
||||
}
|
||||
selectedUserID := strings.TrimSpace(selected.UserID)
|
||||
legacyUserID := strings.TrimSpace(legacy.UserID)
|
||||
if legacyUserID != "" {
|
||||
return legacyUserID == selectedUserID
|
||||
}
|
||||
return legacyGlobalBlankUserIDMatchesSingleProfile(configDir, selectedCorpID, selectedUserID)
|
||||
}
|
||||
|
||||
func legacyGlobalBlankUserIDMatchesSingleProfile(configDir, corpID, userID string) bool {
|
||||
if strings.TrimSpace(corpID) == "" {
|
||||
return false
|
||||
}
|
||||
cfg, err := tokenLoadProfiles(configDir)
|
||||
if err != nil || cfg == nil {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected", "reason", "profiles_error", "error", err)
|
||||
return false
|
||||
}
|
||||
profiles := profilesForCorpID(cfg, corpID)
|
||||
if len(profiles) != 1 {
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "multi_profile",
|
||||
"corp_id", strings.TrimSpace(corpID),
|
||||
"profile_count", len(profiles),
|
||||
)
|
||||
return false
|
||||
}
|
||||
profile := profiles[0]
|
||||
if profile != nil && sameProfileIdentity(profile.CorpID, profile.UserID, corpID, userID) {
|
||||
return true
|
||||
}
|
||||
profileUserID := ""
|
||||
if profile != nil {
|
||||
profileUserID = strings.TrimSpace(profile.UserID)
|
||||
}
|
||||
logging.AuthDebug("auth.refresh.legacy_recovery.blank_user_rejected",
|
||||
"reason", "identity_mismatch",
|
||||
"selected_user_id", strings.TrimSpace(userID),
|
||||
"profile_user_id", profileUserID,
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
// ExchangeAuthCode takes an AuthCode and an optional UserID provided by an
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageIsRefreshTokenRejected(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want bool
|
||||
}{
|
||||
{"nil", nil, false},
|
||||
{"mcp authCode.notFound", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}, true},
|
||||
{"mcp other business code", &MCPTokenExchangeError{Code: "other.error", Message: "boom"}, false},
|
||||
{"wrapped mcp rejection", fmt.Errorf("refresh: %w", &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode}), true},
|
||||
{"http 400 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusBadRequest}, false},
|
||||
{"http 401 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusUnauthorized}, false},
|
||||
{"http 403 has no reviewed business code", &HTTPStatusError{StatusCode: http.StatusForbidden}, false},
|
||||
{"http 500 is transient", &HTTPStatusError{StatusCode: http.StatusInternalServerError}, false},
|
||||
{"http 429 is transient", &HTTPStatusError{StatusCode: http.StatusTooManyRequests}, false},
|
||||
{"plain error is unknown", errors.New("boom"), false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isRefreshTokenRejected(tt.err); got != tt.want {
|
||||
t.Fatalf("isRefreshTokenRejected(%v) = %v, want %v", tt.err, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// orgSlotFallbackFixture wires the injectable seams lockedRefresh depends on
|
||||
// and records refresh attempts plus organization slot lookups.
|
||||
type orgSlotFallbackFixture struct {
|
||||
provider *OAuthProvider
|
||||
stale *TokenData
|
||||
orgMirror *TokenData
|
||||
renewed *TokenData
|
||||
rejected *MCPTokenExchangeError
|
||||
refreshErr error
|
||||
orgRefreshErr error
|
||||
refreshCalls []string
|
||||
refreshUserIDs []string
|
||||
orgLoads int
|
||||
}
|
||||
|
||||
func newOrgSlotFallbackFixture(t *testing.T) *orgSlotFallbackFixture {
|
||||
t.Helper()
|
||||
isolateOAuthPersistence(t)
|
||||
|
||||
f := &orgSlotFallbackFixture{
|
||||
provider: &OAuthProvider{configDir: t.TempDir(), logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Output: io.Discard},
|
||||
stale: &TokenData{
|
||||
AccessToken: "old-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
orgMirror: &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
renewed: &TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
},
|
||||
rejected: &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"},
|
||||
}
|
||||
f.refreshErr = f.rejected
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(configDir, _ string) (*TokenData, error) { return oauthLoadToken(configDir) })
|
||||
testseam.Swap(t, &oauthLoadToken, func(string) (*TokenData, error) { return f.stale, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(_ *OAuthProvider, _ context.Context, data *TokenData) (*TokenData, error) {
|
||||
f.refreshCalls = append(f.refreshCalls, data.RefreshToken)
|
||||
f.refreshUserIDs = append(f.refreshUserIDs, data.UserID)
|
||||
switch data.RefreshToken {
|
||||
case "stale-refresh":
|
||||
return nil, f.refreshErr
|
||||
case "org-refresh":
|
||||
return f.renewed, f.orgRefreshErr
|
||||
}
|
||||
return nil, fmt.Errorf("unexpected refresh token %q", data.RefreshToken)
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(corpID string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
if corpID != "corp-1" {
|
||||
return nil, ErrTokenDataNotFound
|
||||
}
|
||||
return f.orgMirror, nil
|
||||
})
|
||||
return f
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallsBackToOrgSlot(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 || f.refreshCalls[0] != "stale-refresh" || f.refreshCalls[1] != "org-refresh" {
|
||||
t.Fatalf("refresh attempts = %v, want [stale-refresh org-refresh]", f.refreshCalls)
|
||||
}
|
||||
if f.orgLoads != 1 {
|
||||
t.Fatalf("organization slot loads = %d, want 1", f.orgLoads)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepairMarkerForcesOrganizationSlotWrite(t *testing.T) {
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "legacy", CorpID: "corp-1", UserID: ""},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1"},
|
||||
},
|
||||
}
|
||||
selector := profileSelector("corp-1", "user-1")
|
||||
|
||||
without := &TokenData{CorpID: "corp-1", UserID: "user-1"}
|
||||
if plan := planTokenPersistenceWrites(cfg, without, selector); plan.WriteOrganization {
|
||||
t.Fatalf("explicit selector preserved unresolved org slot: WriteOrganization = true, want false")
|
||||
}
|
||||
|
||||
with := &TokenData{CorpID: "corp-1", UserID: "user-1", RepairOrganizationMirror: true}
|
||||
if plan := planTokenPersistenceWrites(cfg, with, selector); !plan.WriteOrganization {
|
||||
t.Fatalf("repair marker did not force the organization slot write")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshFallbackRepairsPersistedSlots(t *testing.T) {
|
||||
isolateOAuthPersistence(t)
|
||||
t.Setenv("DWS_CLIENT_ID", "")
|
||||
t.Setenv("DWS_CLIENT_SECRET", "")
|
||||
|
||||
// Fake MCP refresh endpoint: the first call rejects the stale identity
|
||||
// refresh_token with the reviewed business code; the second call (the
|
||||
// organization mirror) succeeds and returns a rotated credential.
|
||||
var refreshCalls atomic.Int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if refreshCalls.Add(1) == 1 {
|
||||
fmt.Fprint(w, `{"errorCode":"invalidParameter.authCode.notFound","errorMsg":"authCode not found"}`)
|
||||
return
|
||||
}
|
||||
fmt.Fprint(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":7200,"corpId":"corp-1","userId":"user-1","userName":"User One"}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
configDir := setupMCPConfigDir(t, srv.URL)
|
||||
resetAppConfigCache()
|
||||
|
||||
// Seed the pre-fallback state: an identity slot whose refresh_token the
|
||||
// server rejects, plus a legacy organization mirror (no userId) with a
|
||||
// still-valid refresh_token and a preserved unresolved sibling profile so
|
||||
// an explicit --profile refresh would normally skip the org slot.
|
||||
cfg := &ProfilesConfig{
|
||||
Version: profilesVersion,
|
||||
Profiles: []Profile{
|
||||
{Name: "corp-1", CorpID: "corp-1", UserID: "", ClientID: "mcp-client"},
|
||||
{Name: "user-1", CorpID: "corp-1", UserID: "user-1", UserName: "User One", ClientID: "mcp-client"},
|
||||
},
|
||||
}
|
||||
if err := SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
orgMirror := &TokenData{
|
||||
AccessToken: "org-access",
|
||||
RefreshToken: "org-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Minute),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForCorpID("corp-1", orgMirror); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
staleIdentity := &TokenData{
|
||||
AccessToken: "stale-access",
|
||||
RefreshToken: "stale-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
UserName: "User One",
|
||||
Source: "mcp",
|
||||
ClientID: "mcp-client",
|
||||
}
|
||||
if err := SaveTokenDataKeychainForIdentity("corp-1", "user-1", staleIdentity); err != nil {
|
||||
t.Fatalf("SaveTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
|
||||
SetRuntimeProfile("corp-1:user-1")
|
||||
t.Cleanup(func() { SetRuntimeProfile("") })
|
||||
|
||||
p := &OAuthProvider{
|
||||
configDir: configDir,
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
Output: io.Discard,
|
||||
httpClient: srv.Client(),
|
||||
}
|
||||
got, err := p.lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if got == nil || got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("lockedRefresh() = %#v, want rotated credential", got)
|
||||
}
|
||||
if refreshCalls.Load() != 2 {
|
||||
t.Fatalf("MCP refresh calls = %d, want primary rejection plus fallback", refreshCalls.Load())
|
||||
}
|
||||
|
||||
// The fallback consumed the mirror's refresh_token: both persisted slots
|
||||
// must now carry the rotated credential instead of the consumed one.
|
||||
orgSlot, err := LoadTokenDataKeychainForCorpID("corp-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForCorpID() error = %v", err)
|
||||
}
|
||||
if orgSlot.RefreshToken != "new-refresh" || orgSlot.UserID != "user-1" {
|
||||
t.Fatalf("organization slot = %#v, want new-refresh for user-1", orgSlot)
|
||||
}
|
||||
identitySlot, err := LoadTokenDataKeychainForIdentity("corp-1", "user-1")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadTokenDataKeychainForIdentity() error = %v", err)
|
||||
}
|
||||
if identitySlot.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("identity slot = %#v, want new-refresh", identitySlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLockedRefreshOrgSlotFallbackGuardrails(t *testing.T) {
|
||||
t.Run("transient failure does not fall back", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusInternalServerError}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want transient failure", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 for transient failure", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing org slot preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, ErrTokenDataNotFound
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil org data from keychain preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) {
|
||||
f.orgLoads++
|
||||
return nil, nil
|
||||
})
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("org slot refresh failure preserves rejection", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgRefreshErr = fmt.Errorf("org mirror refresh failed")
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want primary and fallback attempts", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("different user in org slot is rejected", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = "user-2"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, mismatched user must not refresh", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty org slot user identity is backfilled", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.UserID = ""
|
||||
f.orgMirror.UserName = ""
|
||||
|
||||
got, err := f.provider.lockedRefresh(context.Background())
|
||||
if err != nil || got != f.renewed {
|
||||
t.Fatalf("lockedRefresh() = %#v, %v; want renewed token, nil", got, err)
|
||||
}
|
||||
if len(f.refreshCalls) != 2 {
|
||||
t.Fatalf("refresh attempts = %v, want fallback attempt", f.refreshCalls)
|
||||
}
|
||||
if f.refreshUserIDs[1] != "user-1" {
|
||||
t.Fatalf("fallback refresh UserID = %q, want backfilled current identity user-1", f.refreshUserIDs[1])
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("same rejected refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshToken = "stale-refresh"
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, retrying the rejected token must not run", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("expired org refresh token is skipped", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.orgMirror.RefreshExpAt = time.Now().Add(-time.Hour)
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("missing corp id skips fallback", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.stale.CorpID = ""
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if !errors.Is(err, f.rejected) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("organization slot loads = %d, want 0 without corpId", f.orgLoads)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("direct mode terminal status does not fall back without business code", func(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
f.refreshErr = &HTTPStatusError{StatusCode: http.StatusBadRequest}
|
||||
|
||||
_, err := f.provider.lockedRefresh(context.Background())
|
||||
if err == nil || err.Error() != f.refreshErr.Error() {
|
||||
t.Fatalf("lockedRefresh() error = %v, want direct terminal status", err)
|
||||
}
|
||||
if f.orgLoads != 0 {
|
||||
t.Fatalf("fallback slot loads = %d, want 0 without reviewed business code", f.orgLoads)
|
||||
}
|
||||
if len(f.refreshCalls) != 1 {
|
||||
t.Fatalf("refresh attempts = %v, want only the primary attempt", f.refreshCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRefreshFromOrgSlotBoundaries(t *testing.T) {
|
||||
f := newOrgSlotFallbackFixture(t)
|
||||
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), nil); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot(nil) succeeded")
|
||||
}
|
||||
f.orgMirror.CorpID = "corp-2"
|
||||
if _, err := f.provider.refreshFromOrgSlot(context.Background(), f.stale); err == nil {
|
||||
t.Fatal("refreshFromOrgSlot with mismatched corpId succeeded")
|
||||
}
|
||||
if len(f.refreshCalls) != 0 {
|
||||
t.Fatalf("refresh attempts = %v, corpId mismatch must not refresh", f.refreshCalls)
|
||||
}
|
||||
}
|
||||
@@ -87,3 +87,16 @@ func ClassifyRefreshFailure(err error) RefreshFailureClass {
|
||||
}
|
||||
return RefreshFailureUnknown
|
||||
}
|
||||
|
||||
// isRefreshTokenRejected reports whether the server returned a reviewed
|
||||
// business code that definitively rejects the presented refresh_token.
|
||||
// Only the reviewed MCP business code enables the organization-slot
|
||||
// fallback; direct-mode terminal HTTP rejections (400/401/403) carry no
|
||||
// reviewed business code and deliberately do not trigger the fallback.
|
||||
func isRefreshTokenRejected(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var exchangeErr *MCPTokenExchangeError
|
||||
return errors.As(err, &exchangeErr) && exchangeErr != nil && exchangeErr.requiresReauthorization()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
@@ -189,6 +190,195 @@ func TestCrossPlatformCoverageGetTokenSnapshotOnlyExpiresProfileForNonTransientR
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRecoversRejectedIdentityRefresh(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "V1039 User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
UserName: selected.UserName,
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
var saved *TokenData
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(_ string, data *TokenData) error {
|
||||
copy := *data
|
||||
saved = ©
|
||||
return nil
|
||||
})
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if recovered.AccessToken != legacy.AccessToken || recovered.RefreshToken != legacy.RefreshToken {
|
||||
t.Fatalf("recovered token = %#v, want legacy credential material %#v", recovered, legacy)
|
||||
}
|
||||
if recovered.UserID != selected.UserID || recovered.UserName != selected.UserName {
|
||||
t.Fatalf("recovered identity = %q/%q, want selected identity %q/%q", recovered.UserID, recovered.UserName, selected.UserID, selected.UserName)
|
||||
}
|
||||
if saved == nil || saved.AccessToken != recovered.AccessToken || saved.UserID != selected.UserID {
|
||||
t.Fatalf("saved recovery token = %#v, want recovered identity token %#v", saved, recovered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-multi",
|
||||
UserID: "user-v1039-a",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "User A", CorpID: selected.CorpID, UserID: selected.UserID},
|
||||
{Name: "User B", CorpID: selected.CorpID, UserID: "user-v1039-b"},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsBlankSelectedUserIDForMultiAccountCorp(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-blank-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{
|
||||
{Name: "Blank A", CorpID: selected.CorpID, UserID: ""},
|
||||
{Name: "Blank B", CorpID: selected.CorpID, UserID: ""},
|
||||
}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-selected token for a multi-account organization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsDifferentUserID(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-user-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token owned by a different user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsolated(t *testing.T) {
|
||||
fixture := seedBlankProfileSelectorFixture(t, "Fixture Organization", "Fixture Organization", true)
|
||||
expired := *fixture.blankToken
|
||||
@@ -253,3 +443,330 @@ func TestCrossPlatformCoverageLegacyRefreshFailureKeepsBlankCurrentSelectorIsola
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSingleProfileIdentityMismatch(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-single-mismatch",
|
||||
UserID: "user-v1039-selected",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{
|
||||
Name: "Other User",
|
||||
CorpID: selected.CorpID,
|
||||
UserID: "user-v1039-other",
|
||||
}}}, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token whose single profile identity does not match")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRefreshesExpiredLegacyCredential(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-legacy-refresh",
|
||||
UserID: "user-v1039",
|
||||
UserName: "V1039 User",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
refreshed := &TokenData{
|
||||
AccessToken: "refreshed-legacy-access",
|
||||
RefreshToken: "rotated-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
refreshCalls := 0
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
refreshCalls++
|
||||
if refreshCalls == 1 {
|
||||
return nil, rejection
|
||||
}
|
||||
return refreshed, nil
|
||||
})
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
|
||||
recovered, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("lockedRefresh() error = %v", err)
|
||||
}
|
||||
if refreshCalls != 2 {
|
||||
t.Fatalf("oauthRefreshToken called %d times, want 2 (identity rejection + legacy refresh)", refreshCalls)
|
||||
}
|
||||
if recovered.AccessToken != refreshed.AccessToken {
|
||||
t.Fatalf("recovered access token = %q, want refreshed legacy credential %q", recovered.AccessToken, refreshed.AccessToken)
|
||||
}
|
||||
if recovered.RefreshToken != refreshed.RefreshToken {
|
||||
t.Fatalf("recovered refresh token = %q, want rotated credential %q", recovered.RefreshToken, refreshed.RefreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsProfilesLoadError(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "rejected-identity-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-profiles-error",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-global-access",
|
||||
RefreshToken: "valid-legacy-global-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) { return nil, errors.New("profiles read failed") })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a blank-user token when profiles could not be loaded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsSameRefreshToken(t *testing.T) {
|
||||
selected := &TokenData{
|
||||
AccessToken: "expired-identity-access",
|
||||
RefreshToken: "shared-rejected-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp-v1039-same-refresh",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-global-access",
|
||||
RefreshToken: selected.RefreshToken,
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
|
||||
testseam.Swap(t, &oauthAcquireLock, func(context.Context, string) (*DualLock, error) { return &DualLock{}, nil })
|
||||
testseam.Swap(t, &oauthLoadTokenLocked, func(string, string) (*TokenData, error) { return selected, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) { return nil, rejection })
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &tokenLoadKeychainForCorpID, func(string) (*TokenData, error) { return nil, ErrTokenDataNotFound })
|
||||
saved := false
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error {
|
||||
saved = true
|
||||
return nil
|
||||
})
|
||||
|
||||
_, err := NewOAuthProvider(t.TempDir(), nil).lockedRefresh(context.Background())
|
||||
if !errors.Is(err, rejection) {
|
||||
t.Fatalf("lockedRefresh() error = %v, want original rejection", err)
|
||||
}
|
||||
if saved {
|
||||
t.Fatal("legacy global recovery saved a token holding the same rejected refresh_token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalSlotRejectsNilSelectedAndEmptyLegacy(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
|
||||
// nil selected must be rejected before any dereference.
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), nil, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil selected error = %v, want original rejection", err)
|
||||
}
|
||||
|
||||
// A keychain load that returns (nil, nil) must be rejected before any dereference.
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return nil, nil })
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-nil-legacy",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("nil legacy error = %v, want original rejection", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsNonReauthorizationErrors(t *testing.T) {
|
||||
provider := NewOAuthProvider(t.TempDir(), nil)
|
||||
selected := &TokenData{CorpID: "corp-v1039-plain", UserID: "user-v1039", Source: "mcp"}
|
||||
|
||||
plainErr := errors.New("plain refresh failure")
|
||||
if _, err := provider.recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, plainErr); !errors.Is(err, plainErr) {
|
||||
t.Fatalf("plain error = %v, want original plain failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalRecoveryRejectsSaveAndRefreshFailures(t *testing.T) {
|
||||
rejection := &MCPTokenExchangeError{Code: legacyMCPRefreshRejectedCode, Message: "authCode not found"}
|
||||
selected := &TokenData{
|
||||
CorpID: "corp-v1039-recovery-steps",
|
||||
UserID: "user-v1039",
|
||||
Source: "mcp",
|
||||
}
|
||||
|
||||
t.Run("save_failure", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "valid-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthSaveTokenLocked, func(string, *TokenData) error { return errors.New("save failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("save failure error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_expired", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "expired-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(-time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("expired refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refresh_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &oauthRefreshToken, func(*OAuthProvider, context.Context, *TokenData) (*TokenData, error) {
|
||||
return nil, errors.New("legacy refresh failed")
|
||||
})
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("legacy refresh error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("preflight_error", func(t *testing.T) {
|
||||
legacy := &TokenData{
|
||||
AccessToken: "expired-legacy-access",
|
||||
RefreshToken: "valid-legacy-refresh",
|
||||
ExpiresAt: time.Now().Add(-time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: selected.CorpID,
|
||||
UserID: selected.UserID,
|
||||
Source: "mcp",
|
||||
}
|
||||
testseam.Swap(t, &tokenLoadKeychain, func() (*TokenData, error) { return legacy, nil })
|
||||
testseam.Swap(t, &profilesReadFile, func(string) ([]byte, error) { return nil, errors.New("read failed") })
|
||||
if _, err := NewOAuthProvider(t.TempDir(), nil).recoverRefreshFromLegacyGlobalSlot(context.Background(), selected, rejection); !errors.Is(err, rejection) {
|
||||
t.Fatalf("preflight error = %v, want original rejection", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageLegacyGlobalCandidateMatchingBoundaries(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
selected := &TokenData{CorpID: "corp-v1039-candidate", UserID: "user-v1039"}
|
||||
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, nil) {
|
||||
t.Fatal("nil legacy accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, selected, &TokenData{CorpID: "corp-other", UserID: selected.UserID}) {
|
||||
t.Fatal("different corp accepted")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, &TokenData{UserID: "user-v1039"}, &TokenData{UserID: "user-v1039"}) {
|
||||
t.Fatal("blank selected corp accepted")
|
||||
}
|
||||
|
||||
blankSelected := &TokenData{CorpID: selected.CorpID}
|
||||
testseam.Swap(t, &tokenLoadProfiles, func(string) (*ProfilesConfig, error) {
|
||||
return &ProfilesConfig{Version: profilesVersion, Profiles: []Profile{{Name: "Blank User", CorpID: selected.CorpID}}}, nil
|
||||
})
|
||||
if !legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID}) {
|
||||
t.Fatal("both blank user IDs should match only through the single-profile guard")
|
||||
}
|
||||
if legacyGlobalRefreshCandidateMatches(configDir, blankSelected, &TokenData{CorpID: selected.CorpID, UserID: "user-other"}) {
|
||||
t.Fatal("blank selected with non-blank legacy accepted")
|
||||
}
|
||||
|
||||
if legacyGlobalBlankUserIDMatchesSingleProfile(configDir, "", selected.UserID) {
|
||||
t.Fatal("blank corp accepted by single-profile check")
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -107,6 +107,13 @@ type TokenData struct {
|
||||
// transient marker to reject ambiguous UID-less logins without breaking
|
||||
// legitimate refreshes of unresolved accounts.
|
||||
FreshAuthorization bool `json:"-"`
|
||||
// RepairOrganizationMirror marks a fallback refresh that consumed the
|
||||
// organization mirror's refresh_token. The regular write plan can skip the
|
||||
// organization slot under an explicit runtime selector (for example when an
|
||||
// unresolved sibling profile still owns it), which would strand a
|
||||
// refresh_token the server has already rotated; the marker forces the
|
||||
// rotated credential back into that slot.
|
||||
RepairOrganizationMirror bool `json:"-"`
|
||||
}
|
||||
|
||||
// tokenPersistenceWritePlan is the single source of truth for deciding which
|
||||
@@ -127,6 +134,7 @@ type tokenPersistenceWritePlan struct {
|
||||
ExistingIdentity bool
|
||||
UpgradesLegacyProfile bool
|
||||
PreserveUnresolvedOrganization bool
|
||||
RepairOrganizationMirror bool
|
||||
WriteIdentity bool
|
||||
WriteOrganization bool
|
||||
WriteGlobal bool
|
||||
@@ -167,6 +175,11 @@ func planTokenPersistenceWrites(
|
||||
plan.PreserveUnresolvedOrganization = plan.UserID != "" &&
|
||||
unresolvedProfileForCorp(cfg, plan.CorpID) != nil &&
|
||||
!plan.UpgradesLegacyProfile
|
||||
// A fallback refresh consumed the organization mirror's refresh_token;
|
||||
// the rotated credential must go back into that slot even when the
|
||||
// selector-driven plan would skip it (for example an explicit --profile
|
||||
// that preserves an unresolved sibling profile's slot).
|
||||
plan.RepairOrganizationMirror = data.RepairOrganizationMirror
|
||||
plan.WriteIdentity = plan.UserID != ""
|
||||
orgCurrentSelector := ""
|
||||
if cfg != nil {
|
||||
@@ -177,7 +190,8 @@ func planTokenPersistenceWrites(
|
||||
// reauthorization. Its organization slot must move with the newly exact
|
||||
// identity even when an explicit runtime selector keeps it from becoming
|
||||
// process-global current.
|
||||
plan.WriteOrganization = plan.UserID == "" ||
|
||||
plan.WriteOrganization = plan.RepairOrganizationMirror ||
|
||||
plan.UserID == "" ||
|
||||
plan.UpgradesLegacyProfile ||
|
||||
(!plan.PreserveUnresolvedOrganization &&
|
||||
(plan.MakeCurrent ||
|
||||
@@ -387,6 +401,7 @@ func saveTokenDataLocked(configDir string, data *TokenData) error {
|
||||
"persistence_profile", plan.PersistenceSelector,
|
||||
"write_identity_slot", plan.WriteIdentity,
|
||||
"write_org_mirror", plan.WriteOrganization,
|
||||
"repair_org_mirror", plan.RepairOrganizationMirror,
|
||||
"write_global_mirror", plan.WriteGlobal,
|
||||
"publish_incoming_global", plan.MakeCurrent,
|
||||
)
|
||||
|
||||
@@ -191,12 +191,12 @@
|
||||
"from": "oa +list-processes",
|
||||
"mode": "ambiguous",
|
||||
"candidates": [
|
||||
"oa +list-forms",
|
||||
"oa +my-initiated",
|
||||
"oa +search-forms",
|
||||
"oa approval list-submitted",
|
||||
"oa approval list-initiated"
|
||||
],
|
||||
"reviewed": true,
|
||||
"review_reason": "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf."
|
||||
"review_reason": "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes."
|
||||
},
|
||||
{
|
||||
"from": "chat +conversation-detail",
|
||||
|
||||
@@ -242,9 +242,9 @@ var generatedCommandPathFallbacks = []CommandPathFallback{
|
||||
{
|
||||
From: "oa +list-processes",
|
||||
Mode: "ambiguous",
|
||||
Candidates: []string{"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
Candidates: []string{"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
Reviewed: true,
|
||||
ReviewReason: "20260720 merged evaluation emitted +list-processes, but process can mean approval forms/templates or approval instances initiated by the current user; stop and present both shortcut workflows plus the exact native instance leaf.",
|
||||
ReviewReason: "20260818 review keeps +list-forms unavailable because its live response lacks trustworthy continuation and removes +my-initiated from discovery because guaranteed-zero responses omit hasMore; process can still mean a searchable approval definition or an instance initiated by the current user, so stop and present the public keyword-search or exact atomic initiated routes.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ func TestCrossPlatformCoverageCommandPathFallbackAuditCoverage(t *testing.T) {
|
||||
"chat +send-file": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-image": {"chat +messages-send", "chat message send"},
|
||||
"chat +send-media": {"chat +messages-send", "chat message send"},
|
||||
"oa +list-processes": {"oa +list-forms", "oa +my-initiated", "oa approval list-initiated"},
|
||||
"oa +list-processes": {"oa +search-forms", "oa approval list-submitted", "oa approval list-initiated"},
|
||||
"doc +template": {"doc +template-list", "doc +template-search", "doc +create-from-template"},
|
||||
"doc +version": {"doc +history-list", "doc +history-save", "doc +history-revert"},
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1347
-646
File diff suppressed because one or more lines are too long
@@ -301,7 +301,7 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
t.Fatalf("doc read --content-format required = %#v, want false", contentFormat["required"])
|
||||
}
|
||||
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id"} {
|
||||
for _, flagName := range []string{"scope", "tags", "max-depth", "start-block-id", "end-block-id", "version", "password"} {
|
||||
if parameters[flagName]["required"] != false {
|
||||
t.Fatalf("doc read --%s required = %#v, want false", flagName, parameters[flagName]["required"])
|
||||
}
|
||||
@@ -315,6 +315,15 @@ func TestDeliveryCatalogDocReadParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
if parameters["max-depth"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --max-depth type = %#v, want integer", parameters["max-depth"]["type"])
|
||||
}
|
||||
if got := parameters["version"]["property"]; got != "historyVersion" {
|
||||
t.Fatalf("doc read --version property = %#v, want historyVersion", got)
|
||||
}
|
||||
if parameters["version"]["type"] != "integer" {
|
||||
t.Fatalf("doc read --version type = %#v, want integer", parameters["version"]["type"])
|
||||
}
|
||||
if got := parameters["password"]["property"]; got != "password" {
|
||||
t.Fatalf("doc read --password property = %#v, want password", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeliveryCatalogDocCommentParamDeclsMatchMergeBaseContract(t *testing.T) {
|
||||
|
||||
@@ -76,6 +76,7 @@ var reviewedRuntimeSchemaExclusionGroups = []runtimeSchemaExclusionGroup{
|
||||
"agoal scorecard detail",
|
||||
"agoal scorecard entity-detail",
|
||||
"agoal scorecard update",
|
||||
"agoal scorecard search-entities",
|
||||
"agoal strategy detail",
|
||||
"agoal strategy list",
|
||||
"agoal strategy update",
|
||||
|
||||
@@ -96,7 +96,14 @@ func init() {
|
||||
registerRequireOneOf("sheet.update_cond_format", "ranges", "condition", "cell-style", "data-bar-style")
|
||||
registerRequireOneOf("sheet.update_dimension", "hidden", "pixel-size")
|
||||
registerRequireOneOf("sheet.update_filter_view", "name", "range", "criteria")
|
||||
registerRequireOneOf("sheet.update_float_image", "src", "range", "width", "height", "offset-x", "offset-y")
|
||||
RegisterRuntimeSchemaConstraints("sheet.create_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src"}},
|
||||
})
|
||||
RegisterRuntimeSchemaConstraints("sheet.update_float_image", RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"file", "src"}},
|
||||
RequireOneOf: [][]string{{"file", "src", "range", "width", "height", "offset-x", "offset-y"}},
|
||||
})
|
||||
registerRequireOneOf("sheet.update_sheet", "name", "index", "hidden", "frozen-row-count", "frozen-column-count", "tab-color")
|
||||
registerRequireOneOf("sheet.import", "folder-token", "workspace")
|
||||
registerRequireOneOf("wiki.search_wikiSpaces", "query", "type")
|
||||
|
||||
@@ -408,6 +408,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"doc.insert_document_block --level": "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.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"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.",
|
||||
"doc.style_background_set --color": "Reviewed unpinned adapter: doc.style_background_set has no singular pinned interface_ref; --color is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -476,6 +477,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"drive.list_files --space-id": "drive-branch-only route input on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --thumbnail": "drive-branch-only option on a composite drive/doc command; no singular interface property is advertised",
|
||||
"drive.list_files --workspace": "selects the doc.list_nodes branch of the composite drive/doc command",
|
||||
"drive.list_permission --limit": "The server rejects the legacy maxResults path for list_permission; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"drive.mark_star --node": "Reviewed unpinned adapter: drive.mark_star has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_get --node": "Reviewed unpinned adapter: drive.publish_get has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"drive.publish_set --node": "Reviewed unpinned adapter: drive.publish_set has no singular pinned interface_ref; --node is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -552,6 +554,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.chart_update --properties": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.chart_update --sheet-id": "Reviewed unpinned adapter: sheet.chart_update has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.create_float_image --file": "local Sheet upload input used to obtain a resourceUrl before create_float_image",
|
||||
"sheet.create_pivot_table --properties": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --source": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --source is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.create_pivot_table --target-position": "Reviewed unpinned adapter: sheet.create_pivot_table has no singular pinned interface_ref; --target-position is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -644,6 +647,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"sheet.ungroup_dimension --range": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --range is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.ungroup_dimension --sheet-id": "Reviewed unpinned adapter: sheet.ungroup_dimension has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_cond_format --condition": "one aggregate JSON flag selects one of multiple mutually exclusive RPC condition properties",
|
||||
"sheet.update_float_image --file": "local Sheet upload input used to obtain a resourceUrl before update_float_image",
|
||||
"sheet.update_pivot_table --pivot-table-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --pivot-table-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --properties": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --properties is a CLI wrapper input and does not publish a direct interface property.",
|
||||
"sheet.update_pivot_table --sheet-id": "Reviewed unpinned adapter: sheet.update_pivot_table has no singular pinned interface_ref; --sheet-id is a CLI wrapper input and does not publish a direct interface property.",
|
||||
@@ -664,6 +668,7 @@ var reviewedSchemaParameterMappingExclusions = map[string]string{
|
||||
"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",
|
||||
"wiki.delete_document --workspace": "local validation/authorization context; not sent to delete_document",
|
||||
"wiki.list_member --limit": "The server rejects the legacy maxResults path for list_member; the CLI validates --limit (1-50) and sends it as pageSize at runtime, so --limit is a CLI pagination input without a one-to-one RPC property.",
|
||||
"wiki.list_wikiSpaces --cursor": "composite route maps to pageToken for wiki.list_wikiSpaces or nextToken for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --limit": "composite route maps to pageSize for wiki.list_wikiSpaces or maxResults for drive.list_spaces",
|
||||
"wiki.list_wikiSpaces --type": "route selector maps to wikiSpaceType or spaceType on different composite branches",
|
||||
|
||||
@@ -32,12 +32,18 @@ func ValidateInputSchema(params map[string]any, schema map[string]any) error {
|
||||
if params == nil {
|
||||
params = map[string]any{}
|
||||
}
|
||||
if err := validateSchemaValue("$", params, schema); err != nil {
|
||||
if err := ValidateJSONSchemaValue(params, schema); err != nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("input schema validation failed: %v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateJSONSchemaValue validates one decoded JSON value against the
|
||||
// required/type/enum/properties/items subset used by reviewed CLI contracts.
|
||||
func ValidateJSONSchemaValue(value any, schema map[string]any) error {
|
||||
return validateSchemaValue("$", value, schema)
|
||||
}
|
||||
|
||||
func validateSchemaValue(path string, value any, schema map[string]any) error {
|
||||
if len(schema) == 0 {
|
||||
return nil
|
||||
|
||||
+139
-9
@@ -16,9 +16,10 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
@@ -47,17 +48,14 @@ func NewGroupCommand(use, short string) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: use,
|
||||
Short: short,
|
||||
Args: cobra.NoArgs,
|
||||
TraverseChildren: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
// Tag as a group container: its RunE only prints help, so cobra's
|
||||
// Runnable() can't distinguish it from a real leaf — callers that need to
|
||||
// collapse empty groups rely on this annotation.
|
||||
cmdutil.MarkGroup(cmd)
|
||||
corecmd.ApplyGroupPolicy(cmd, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -90,6 +88,7 @@ func MergeCommandTree(dst, src *cobra.Command) {
|
||||
if dst == nil || src == nil {
|
||||
return
|
||||
}
|
||||
mergeGroupPolicy(dst, src)
|
||||
if dst.Short == "" || (IsGenericOverlayShort(dst.Short) && src.Short != "" && !IsGenericOverlayShort(src.Short)) {
|
||||
dst.Short = src.Short
|
||||
}
|
||||
@@ -113,6 +112,137 @@ func MergeCommandTree(dst, src *cobra.Command) {
|
||||
}
|
||||
}
|
||||
|
||||
func mergeGroupPolicy(dst, src *cobra.Command) {
|
||||
dstPolicy, dstOK, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("destination command %q has invalid GroupPolicy: %v", dst.CommandPath(), err))
|
||||
}
|
||||
srcPolicy, srcOK, err := corecmd.GroupPolicyFor(src)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("source command %q has invalid GroupPolicy: %v", src.CommandPath(), err))
|
||||
}
|
||||
if len(dst.Commands()) > 0 && !dstOK {
|
||||
panic(fmt.Sprintf("destination command %q has children but no GroupPolicy", dst.CommandPath()))
|
||||
}
|
||||
if len(src.Commands()) > 0 && !srcOK {
|
||||
panic(fmt.Sprintf("source command %q has children but no GroupPolicy", src.CommandPath()))
|
||||
}
|
||||
if dstOK && !srcOK {
|
||||
if !isNeutralMergePlaceholder(src) {
|
||||
panic(fmt.Sprintf("cannot merge runnable or behavior-bearing leaf command %q into typed group command %q",
|
||||
src.CommandPath(), dst.CommandPath()))
|
||||
}
|
||||
return
|
||||
}
|
||||
if !dstOK && srcOK {
|
||||
if !isNeutralMergePlaceholder(dst) {
|
||||
panic(fmt.Sprintf("cannot merge typed group command %q into runnable or behavior-bearing leaf command %q",
|
||||
src.CommandPath(), dst.CommandPath()))
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(dst, srcPolicy)
|
||||
return
|
||||
}
|
||||
if dstOK && srcOK && dstPolicy != srcPolicy {
|
||||
// A NavigationOnly/Reject/Sibling source is the framework's neutral
|
||||
// service scaffold (shortcuts and plugin overlays use it before being
|
||||
// folded into an owning product root). The destination owns the merged
|
||||
// command's default action and recovery scope, so preserve its policy.
|
||||
// Any stronger source declaration would lose behavior during this
|
||||
// destination-oriented merge and therefore fails closed.
|
||||
if srcPolicy != (corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
}) {
|
||||
panic(fmt.Sprintf("cannot merge command %q with conflicting GroupPolicy declarations: %+v != %+v",
|
||||
dst.CommandPath(), dstPolicy, srcPolicy))
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// isNeutralMergePlaceholder reports whether cmd contributes metadata only.
|
||||
// Such a shell may adopt (or be folded into) one typed group declaration.
|
||||
// Anything executable, parse-affecting, or child-bearing must declare its own
|
||||
// compatible GroupPolicy so tree assembly cannot silently discard behavior.
|
||||
func isNeutralMergePlaceholder(cmd *cobra.Command) bool {
|
||||
if cmd == nil || len(cmd.Commands()) != 0 || cmd.Runnable() || cmd.Args != nil ||
|
||||
cmd.PreRun != nil || cmd.PreRunE != nil || cmd.PostRun != nil || cmd.PostRunE != nil ||
|
||||
cmd.PersistentPreRun != nil || cmd.PersistentPreRunE != nil ||
|
||||
cmd.PersistentPostRun != nil || cmd.PersistentPostRunE != nil ||
|
||||
cmd.TraverseChildren || cmd.DisableFlagParsing {
|
||||
return false
|
||||
}
|
||||
hasFlags := false
|
||||
cmd.LocalNonPersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
|
||||
cmd.PersistentFlags().VisitAll(func(*pflag.Flag) { hasFlags = true })
|
||||
return !hasFlags
|
||||
}
|
||||
|
||||
// ValidateGroupTree checks the final assembled Cobra tree rather than source
|
||||
// syntax. Every command with children must carry one valid typed GroupPolicy;
|
||||
// leaves must carry none. This catches dynamically assembled aliases, plugin
|
||||
// parents, and constructors outside any one source directory.
|
||||
func ValidateGroupTree(root *cobra.Command) error {
|
||||
if root == nil {
|
||||
return fmt.Errorf("cannot validate a nil command tree")
|
||||
}
|
||||
return validateGroupNode(root)
|
||||
}
|
||||
|
||||
func validateGroupNode(cmd *cobra.Command) error {
|
||||
policy, declared, err := corecmd.GroupPolicyFor(cmd)
|
||||
if err != nil {
|
||||
return fmt.Errorf("command %q has invalid GroupPolicy metadata: %w", cmd.CommandPath(), err)
|
||||
}
|
||||
children := cmd.Commands()
|
||||
if len(children) == 0 {
|
||||
if declared {
|
||||
return fmt.Errorf("leaf command %q retains GroupPolicy %+v", cmd.CommandPath(), policy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !declared {
|
||||
return fmt.Errorf("command %q has children but no GroupPolicy", cmd.CommandPath())
|
||||
}
|
||||
if !cmd.Runnable() {
|
||||
return fmt.Errorf("group command %q with mode %q is not runnable", cmd.CommandPath(), policy.Mode)
|
||||
}
|
||||
if policy.Mode == corecmd.GroupNavigationOnly && (cmd.RunE == nil || cmd.Run != nil) {
|
||||
return fmt.Errorf("navigation-only group %q does not retain framework help execution", cmd.CommandPath())
|
||||
}
|
||||
if policy.Mode == corecmd.GroupHybrid && cmd.RunE == nil {
|
||||
return fmt.Errorf("hybrid group %q lost its business RunE", cmd.CommandPath())
|
||||
}
|
||||
if policy.Positionals == corecmd.PositionalsAllow && cmd.Args == nil {
|
||||
return fmt.Errorf("group command %q allows positionals without an explicit Args contract", cmd.CommandPath())
|
||||
}
|
||||
if policy.Positionals == corecmd.PositionalsReject {
|
||||
if cmd.Args == nil {
|
||||
return fmt.Errorf("group command %q rejects positionals without compiled Args behavior", cmd.CommandPath())
|
||||
}
|
||||
}
|
||||
if policy.Recovery == corecmd.RecoveryDeep && !hasAvailableDescendant(cmd) {
|
||||
return fmt.Errorf("group command %q declares deep recovery without an available descendant", cmd.CommandPath())
|
||||
}
|
||||
for _, child := range children {
|
||||
if err := validateGroupNode(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasAvailableDescendant(cmd *cobra.Command) bool {
|
||||
for _, child := range cmd.Commands() {
|
||||
if !child.IsAvailableCommand() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ShouldReplaceLeaf decides whether src should replace dst as a leaf command
|
||||
// based on override priority and local flag count.
|
||||
func ShouldReplaceLeaf(dst, src *cobra.Command) bool {
|
||||
|
||||
+391
-10
@@ -14,8 +14,10 @@
|
||||
package cobracmd
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -143,20 +145,399 @@ func TestNewGroupCommand(t *testing.T) {
|
||||
t.Fatalf("Short = %q, want %q", cmd.Short, "my group description")
|
||||
}
|
||||
if cmd.Args == nil {
|
||||
t.Fatal("Args should be set (cobra.NoArgs)")
|
||||
t.Fatal("Args should be set (cobra.ArbitraryArgs)")
|
||||
}
|
||||
// Verify Args rejects arguments.
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
|
||||
t.Fatal("expected Args to reject extra arguments")
|
||||
// Args must reach the shared resolver instead of Cobra's generic arg error.
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err != nil {
|
||||
t.Fatalf("Args intercepted command resolution: %v", err)
|
||||
}
|
||||
// Verify RunE is set and returns help (no error for valid invocation).
|
||||
if cmd.RunE == nil {
|
||||
t.Fatal("RunE should not be nil")
|
||||
}
|
||||
policy, ok, err := corecmd.GroupPolicyFor(cmd)
|
||||
if err != nil || !ok || policy != (corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly,
|
||||
Positionals: corecmd.PositionalsReject,
|
||||
Recovery: corecmd.RecoverySibling,
|
||||
}) {
|
||||
t.Fatalf("GroupPolicyFor() = %+v, %v, %v", policy, ok, err)
|
||||
}
|
||||
// RunE calls cmd.Help() which should not error.
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("RunE returned unexpected error: %v", err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("RunE typo error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupTree(t *testing.T) {
|
||||
t.Run("valid final tree", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
nested := NewGroupCommand("nested", "nested")
|
||||
nested.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.AddCommand(nested)
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nil tree", func(t *testing.T) {
|
||||
if err := ValidateGroupTree(nil); err == nil || !strings.Contains(err.Error(), "nil") {
|
||||
t.Fatalf("ValidateGroupTree(nil) = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("children require declaration", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "no GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("leaf rejects stale declaration", func(t *testing.T) {
|
||||
leaf := NewGroupCommand("stale", "stale")
|
||||
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep policy on a leaf is still a stale group declaration", func(t *testing.T) {
|
||||
leaf := &cobra.Command{Use: "stale-deep"}
|
||||
corecmd.ApplyGroupPolicy(leaf, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
if err := ValidateGroupTree(leaf); err == nil || !strings.Contains(err.Error(), "retains GroupPolicy") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("declared group must stay runnable", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = nil
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "not runnable") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejected positionals require compiled Args behavior", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.Args = nil
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "compiled Args") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("allowed positionals require explicit Args contract", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "explicit Args") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("validation does not execute positional contracts", func(t *testing.T) {
|
||||
calls := 0
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Args: func(*cobra.Command, []string) error {
|
||||
calls++
|
||||
return nil
|
||||
},
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsAllow, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
if calls != 0 {
|
||||
t.Fatalf("ValidateGroupTree executed Args %d times", calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep recovery requires available descendants", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "hidden", Hidden: true, RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "available descendant") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupTreeFailsClosedOnCorruption(t *testing.T) {
|
||||
t.Run("malformed policy metadata", func(t *testing.T) {
|
||||
root := &cobra.Command{
|
||||
Use: "dws",
|
||||
Annotations: map[string]string{
|
||||
"dws.internal.corecmd.group_policy.v1": "malformed",
|
||||
},
|
||||
}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "invalid GroupPolicy metadata") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("navigation-only execution hook changed", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = func(*cobra.Command, []string) {}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "does not retain framework help execution") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("hybrid business execution hook removed", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
root.RunE = nil
|
||||
root.Run = func(*cobra.Command, []string) {}
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), "lost its business RunE") {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("nested validation error is propagated", func(t *testing.T) {
|
||||
root := NewGroupCommand("dws", "root")
|
||||
root.AddCommand(NewGroupCommand("stale", "stale"))
|
||||
if err := ValidateGroupTree(root); err == nil || !strings.Contains(err.Error(), `leaf command "dws stale" retains GroupPolicy`) {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("deep recovery accepts an available descendant", func(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
corecmd.ApplyGroupPolicy(root, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(&cobra.Command{Use: "leaf", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
if err := ValidateGroupTree(root); err != nil {
|
||||
t.Fatalf("ValidateGroupTree() = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMergeCommandTreeGroupPolicy(t *testing.T) {
|
||||
t.Run("copies source declaration", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
MergeCommandTree(dst, src)
|
||||
policy, ok, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil || !ok || policy.Recovery != corecmd.RecoverySibling {
|
||||
t.Fatalf("merged policy = %+v, %v, %v", policy, ok, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed destination accepts metadata-only source", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root", Long: "source details"}
|
||||
MergeCommandTree(dst, src)
|
||||
if dst.Long != "source details" {
|
||||
t.Fatalf("Long = %q", dst.Long)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("accepts identical declarations", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := NewGroupCommand("root", "source")
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("neutral scaffold preserves owning hybrid deep policy", func(t *testing.T) {
|
||||
businessCalled := false
|
||||
dst := &cobra.Command{
|
||||
Use: "root",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
businessCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
want := corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
}
|
||||
corecmd.ApplyGroupPolicy(dst, want)
|
||||
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
|
||||
src := NewGroupCommand("root", "neutral scaffold")
|
||||
src.AddCommand(&cobra.Command{Use: "overlay", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
MergeCommandTree(dst, src)
|
||||
|
||||
got, ok, err := corecmd.GroupPolicyFor(dst)
|
||||
if err != nil || !ok || got != want {
|
||||
t.Fatalf("merged owning policy = %+v, %v, %v; want %+v", got, ok, err, want)
|
||||
}
|
||||
if ChildByName(dst, "overlay") == nil {
|
||||
t.Fatal("neutral scaffold child was not merged")
|
||||
}
|
||||
if err := dst.RunE(dst, nil); err != nil || !businessCalled {
|
||||
t.Fatalf("owning Hybrid RunE was not preserved: called=%v err=%v", businessCalled, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects conflicting declarations", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDisabled,
|
||||
})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("hybrid deep target rejects non-neutral source", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
corecmd.ApplyGroupPolicy(dst, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupHybrid, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
src := &cobra.Command{Use: "root"}
|
||||
corecmd.ApplyGroupPolicy(src, corecmd.GroupPolicy{
|
||||
Mode: corecmd.GroupNavigationOnly, Positionals: corecmd.PositionalsReject, Recovery: corecmd.RecoveryDeep,
|
||||
})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "conflicting GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not overwrite undeclared runnable destination", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
src := NewGroupCommand("root", "source")
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not swallow runnable leaf source into group destination", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dst.AddCommand(&cobra.Command{Use: "native", RunE: func(*cobra.Command, []string) error { return nil }})
|
||||
src := &cobra.Command{Use: "root", RunE: func(*cobra.Command, []string) error { return nil }}
|
||||
src.Flags().String("source-only", "", "must not be silently discarded")
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("does not swallow parse behavior from source into group destination", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root", Args: cobra.NoArgs}
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "behavior-bearing leaf") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
|
||||
t.Run("rejects undeclared destination group", func(t *testing.T) {
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst.AddCommand(&cobra.Command{Use: "child"})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "destination command") || !strings.Contains(got.(string), "no GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(dst, &cobra.Command{Use: "root"})
|
||||
})
|
||||
|
||||
t.Run("rejects undeclared source group", func(t *testing.T) {
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.AddCommand(&cobra.Command{Use: "child"})
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), "source command") || !strings.Contains(got.(string), "no GroupPolicy") {
|
||||
t.Fatalf("MergeCommandTree panic = %v", got)
|
||||
}
|
||||
}()
|
||||
MergeCommandTree(&cobra.Command{Use: "root"}, src)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageMergeCommandTreeFailsClosedOnCorruption(t *testing.T) {
|
||||
mustPanic := func(t *testing.T, want string, fn func()) {
|
||||
t.Helper()
|
||||
defer func() {
|
||||
got := recover()
|
||||
message, ok := got.(string)
|
||||
if !ok || !strings.Contains(message, want) {
|
||||
t.Fatalf("panic = %v, want substring %q", got, want)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
}
|
||||
|
||||
malformed := func() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "root",
|
||||
Annotations: map[string]string{
|
||||
"dws.internal.corecmd.group_policy.v1": "malformed",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("malformed destination policy", func(t *testing.T) {
|
||||
mustPanic(t, "destination command", func() {
|
||||
MergeCommandTree(malformed(), &cobra.Command{Use: "root"})
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("malformed source policy", func(t *testing.T) {
|
||||
mustPanic(t, "source command", func() {
|
||||
MergeCommandTree(&cobra.Command{Use: "root"}, malformed())
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("local flag prevents placeholder merge", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.Flags().String("local", "", "local parse behavior")
|
||||
mustPanic(t, "behavior-bearing leaf", func() {
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("persistent flag prevents placeholder merge", func(t *testing.T) {
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src.PersistentFlags().String("persistent", "", "inherited parse behavior")
|
||||
mustPanic(t, "behavior-bearing leaf", func() {
|
||||
MergeCommandTree(dst, src)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewHiddenGroupCommand(t *testing.T) {
|
||||
@@ -341,11 +722,11 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("child merge recursive", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dstChild := &cobra.Command{Use: "sub", Short: ""}
|
||||
dst.AddCommand(dstChild)
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
srcChild := &cobra.Command{Use: "sub", Short: "Merged short"}
|
||||
src.AddCommand(srcChild)
|
||||
|
||||
@@ -361,12 +742,12 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("leaf replacement by higher priority", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dstLeaf := &cobra.Command{Use: "leaf", Short: "old"}
|
||||
SetOverridePriority(dstLeaf, 1)
|
||||
dst.AddCommand(dstLeaf)
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
srcLeaf := &cobra.Command{Use: "leaf", Short: "new"}
|
||||
SetOverridePriority(srcLeaf, 5)
|
||||
src.AddCommand(srcLeaf)
|
||||
@@ -383,10 +764,10 @@ func TestMergeCommandTree(t *testing.T) {
|
||||
|
||||
t.Run("new child addition", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
dst := &cobra.Command{Use: "root"}
|
||||
dst := NewGroupCommand("root", "destination")
|
||||
dst.AddCommand(&cobra.Command{Use: "existing"})
|
||||
|
||||
src := &cobra.Command{Use: "root"}
|
||||
src := NewGroupCommand("root", "source")
|
||||
src.AddCommand(&cobra.Command{Use: "brand-new", Short: "added"})
|
||||
|
||||
MergeCommandTree(dst, src)
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
// Package commentreaction validates the reviewed DingTalk names accepted by
|
||||
// comment emoji replies. The names mirror the bundled default emoji catalog;
|
||||
// arbitrary text and raw Unicode emoji must never be persisted as reactions.
|
||||
package commentreaction
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
)
|
||||
|
||||
// Validate rejects values outside DingTalk's reviewed default reaction-name
|
||||
// catalog. Names are case-sensitive because they are sent verbatim to the
|
||||
// doc-comment service.
|
||||
func Validate(value string) error {
|
||||
name := strings.TrimSpace(value)
|
||||
if value != name || !utf8.ValidString(name) || !supported(name) {
|
||||
return apperrors.NewValidation(
|
||||
"--reaction/--content 必须是受支持的钉钉表情名称(如 憨笑、鼓掌、比心、赞),不要传 Unicode Emoji 或任意文本",
|
||||
apperrors.WithReason("unsupported_comment_reaction"),
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func supported(name string) bool {
|
||||
switch name {
|
||||
case "微笑", "可爱", "憨笑", "色", "发呆", "老板", "傻笑", "流泪", "害羞", "闭嘴",
|
||||
"睡", "大哭", "尴尬", "感谢", "拒绝", "赞", "鼓掌", "打招呼", "666", "抱拳",
|
||||
"握手", "OK", "胜利", "向左", "向右", "向上", "向下", "来呀", "一点点", "捏住",
|
||||
"比心", "送花花", "加油干", "调皮", "大笑", "惊讶", "流汗", "奋斗", "口罩", "生病",
|
||||
"吐", "难过", "抓狂", "右哼哼", "太阳", "月亮", "强", "弱", "彩带", "蛋糕",
|
||||
"骷髅", "撇嘴", "鄙视", "嘘", "思考", "亲亲", "无奈", "感冒", "对不起", "再见",
|
||||
"投降", "哼", "欠扁", "拜托", "可怜", "舒服", "爱意", "财迷", "迷惑", "委屈",
|
||||
"灵感", "天使", "鬼脸", "凄凉", "郁闷", "坏笑", "算账", "PK", "忍者", "衰",
|
||||
"炸弹", "笑哭", "嘿嘿", "捂脸哭", "抠鼻", "流鼻血", "呲牙", "吃瓜", "彩虹", "耶",
|
||||
"发怒", "捂眼睛", "推眼镜", "暗中观察", "脑暴", "冷笑", "热", "开心", "惊喜", "回头",
|
||||
"白眼", "一团乱麻", "黑眼圈", "裂开", "恭喜", "费解", "收到", "快来", "敲打", "捧脸",
|
||||
"Get", "客服", "AR", "小蜜蜂", "虎虎生威", "兔飞猛进", "龙头老大", "蛇来运转", "马上来财", "专注",
|
||||
"忙疯了", "等一等", "一脸苦笑", "王之蔑视", "洪荒之力", "向左看", "向右看", "YYDS", "这边请", "弹射下班",
|
||||
"退退退", "在吗", "让人头大", "摊手", "抱抱", "举手", "开车", "抱大腿", "跪了", "鞠躬",
|
||||
"选我", "元气满满", "会议", "猫咪", "二哈", "狗子", "三多", "承让", "撒花", "礼物",
|
||||
"生日快乐", "爱心", "心碎", "嘴唇", "鲜花", "残花", "干杯", "咖啡", "奶茶", "茶",
|
||||
"OKR", "KPI", "100分", "对勾", "打叉", "气泡", "加一", "Done", "钉子", "出差",
|
||||
"高铁", "火箭", "邮件", "文档", "演示", "表格", "废纸篓", "手机", "时间", "静音",
|
||||
"公文包", "地球", "碳减排", "回收标志", "幼苗", "红包", "锦鲤", "福", "灯笼", "爆竹",
|
||||
"烟花", "恭喜发财", "月饼", "鸡腿", "休假", "火", "点赞", "平安健康", "定胜":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package commentreaction
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCrossPlatformCoverageValidate(t *testing.T) {
|
||||
for _, value := range []string{"憨笑", "鼓掌", "比心", "赞", "OK", "Done", "平安健康"} {
|
||||
if err := Validate(value); err != nil {
|
||||
t.Errorf("supported reaction %q rejected: %v", value, err)
|
||||
}
|
||||
}
|
||||
for _, value := range []string{"", "😄", "👏", "�", "garbled", "乱码", "憨笑\n"} {
|
||||
if err := Validate(value); err == nil {
|
||||
t.Errorf("unsupported reaction %q accepted", value)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// Package corecmd is the shared, dispatch-agnostic base for building leaf
|
||||
// commands. It concentrates flag registration, the alias/env/default effective
|
||||
// value fallback chain, required validation, cross-flag constraint declaration
|
||||
// checks + runtime enforcement, SafetySpec-driven confirmation, toolArgs
|
||||
// assembly, and Agent Runtime Schema projection.
|
||||
// Package corecmd is the shared, dispatch-agnostic base for building commands.
|
||||
// It concentrates typed group policy, flag registration, the alias/env/default
|
||||
// effective value fallback chain, required validation, cross-flag constraint
|
||||
// declaration checks + runtime enforcement, SafetySpec-driven confirmation,
|
||||
// toolArgs assembly, and Agent Runtime Schema projection.
|
||||
//
|
||||
// Declaration vs execution (framework rule):
|
||||
//
|
||||
@@ -231,7 +231,9 @@ const (
|
||||
// or assemble business params that belong in Flags/ConstParams.
|
||||
//
|
||||
// Exactly one of RunE / Invoke / ResultInvoke / Orchestrate must be set; New
|
||||
// validates this at construction time. corecmd stays dispatch-agnostic and
|
||||
// validates this at construction time. Non-leaf commands are declared
|
||||
// separately through ApplyGroupPolicy so leaf execution fields can never be
|
||||
// configured and then silently ignored. corecmd stays dispatch-agnostic and
|
||||
// never calls a backend: the adapters (FromLeafSpec / FromShortcut) supply the
|
||||
// body.
|
||||
type Spec struct {
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// GroupMode declares whether a command with children is navigation-only or
|
||||
// also owns business execution. The zero value means the command is a leaf.
|
||||
type GroupMode string
|
||||
|
||||
const (
|
||||
// GroupNavigationOnly is a parent whose own invocation only renders help.
|
||||
GroupNavigationOnly GroupMode = "navigation_only"
|
||||
// GroupHybrid is a runnable business command that also owns children.
|
||||
GroupHybrid GroupMode = "hybrid"
|
||||
)
|
||||
|
||||
// PositionalsPolicy declares whether a group may consume positional values.
|
||||
type PositionalsPolicy string
|
||||
|
||||
const (
|
||||
// PositionalsReject makes every unmatched positional token eligible for
|
||||
// command-resolution recovery rather than business execution.
|
||||
PositionalsReject PositionalsPolicy = "reject"
|
||||
// PositionalsAllow reserves positional values for the group's business
|
||||
// execution. Recovery must therefore be disabled to avoid ambiguity.
|
||||
PositionalsAllow PositionalsPolicy = "allow"
|
||||
)
|
||||
|
||||
// RecoveryPolicy declares the search scope for unknown-command recovery.
|
||||
type RecoveryPolicy string
|
||||
|
||||
const (
|
||||
// RecoverySibling suggests only direct children of the current group.
|
||||
RecoverySibling RecoveryPolicy = "sibling"
|
||||
// RecoveryDeep may search all descendants of the current group.
|
||||
RecoveryDeep RecoveryPolicy = "deep"
|
||||
// RecoveryDisabled leaves positional handling entirely to Cobra or the
|
||||
// command's business execution.
|
||||
RecoveryDisabled RecoveryPolicy = "disabled"
|
||||
)
|
||||
|
||||
// GroupPolicy is the typed declaration for every non-leaf command.
|
||||
//
|
||||
// Its zero value deliberately means "leaf": callers must declare all three
|
||||
// fields together for a group. ApplyGroupPolicy compiles the declaration to
|
||||
// Cobra behavior and private framework metadata; command authors must not
|
||||
// author parallel kind annotations themselves.
|
||||
type GroupPolicy struct {
|
||||
Mode GroupMode
|
||||
Positionals PositionalsPolicy
|
||||
Recovery RecoveryPolicy
|
||||
}
|
||||
|
||||
const groupPolicyAnnotation = "dws.internal.corecmd.group_policy.v1"
|
||||
|
||||
// IsZero reports whether p is the leaf declaration.
|
||||
func (p GroupPolicy) IsZero() bool {
|
||||
return p.Mode == "" && p.Positionals == "" && p.Recovery == ""
|
||||
}
|
||||
|
||||
// ValidateGroupPolicy rejects partial declarations, unknown enum values, and
|
||||
// combinations whose parsing semantics would be ambiguous.
|
||||
func ValidateGroupPolicy(p GroupPolicy) error {
|
||||
if p.IsZero() {
|
||||
return nil
|
||||
}
|
||||
switch p.Mode {
|
||||
case GroupNavigationOnly, GroupHybrid:
|
||||
default:
|
||||
return fmt.Errorf("invalid group mode %q", p.Mode)
|
||||
}
|
||||
switch p.Positionals {
|
||||
case PositionalsReject, PositionalsAllow:
|
||||
default:
|
||||
return fmt.Errorf("invalid group positionals policy %q", p.Positionals)
|
||||
}
|
||||
switch p.Recovery {
|
||||
case RecoverySibling, RecoveryDeep, RecoveryDisabled:
|
||||
default:
|
||||
return fmt.Errorf("invalid group recovery policy %q", p.Recovery)
|
||||
}
|
||||
if p.Mode == GroupNavigationOnly && p.Positionals != PositionalsReject {
|
||||
return fmt.Errorf("navigation-only group requires positionals=%q", PositionalsReject)
|
||||
}
|
||||
if p.Positionals == PositionalsAllow && p.Recovery != RecoveryDisabled {
|
||||
return fmt.Errorf("group with positionals=%q requires recovery=%q", PositionalsAllow, RecoveryDisabled)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ApplyGroupPolicy is the sole declaration API for non-leaf command behavior.
|
||||
// Invalid declarations panic because they are framework construction bugs,
|
||||
// matching the fail-closed behavior of Spec flag/constraint declarations.
|
||||
//
|
||||
// Navigation-only groups receive the shared help/unknown-command RunE. Hybrid
|
||||
// groups retain their existing RunE; when they reject positionals, a wrapper
|
||||
// sends non-empty args to the same unknown-command resolver before invoking
|
||||
// business execution. When recovery is enabled, rejecting positionals
|
||||
// deliberately compiles to cobra.ArbitraryArgs: Cobra must not intercept the
|
||||
// token with a generic error before command resolution can produce bounded
|
||||
// guidance. RecoveryDisabled instead compiles rejection to cobra.NoArgs.
|
||||
func ApplyGroupPolicy(cmd *cobra.Command, policy GroupPolicy) {
|
||||
if cmd == nil {
|
||||
panic("cannot apply GroupPolicy to a nil command")
|
||||
}
|
||||
if policy.IsZero() {
|
||||
panic(fmt.Sprintf("command %q cannot apply the zero GroupPolicy; zero means leaf", cmd.Name()))
|
||||
}
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
panic(fmt.Sprintf("command %q declares invalid GroupPolicy: %v", cmd.Name(), err))
|
||||
}
|
||||
if existing, ok, err := GroupPolicyFor(cmd); err != nil {
|
||||
panic(fmt.Sprintf("command %q carries invalid GroupPolicy metadata: %v", cmd.Name(), err))
|
||||
} else if ok && existing != policy {
|
||||
panic(fmt.Sprintf("command %q redeclares GroupPolicy from %+v to %+v", cmd.Name(), existing, policy))
|
||||
} else if ok {
|
||||
return
|
||||
}
|
||||
|
||||
if policy.Mode == GroupNavigationOnly {
|
||||
cmd.Run = nil
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
return runGroupPolicy(cmd, args, policy)
|
||||
}
|
||||
} else if cmd.RunE == nil {
|
||||
panic(fmt.Sprintf("hybrid group %q must declare RunE before GroupPolicy is applied", cmd.Name()))
|
||||
} else if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
||||
businessRunE := cmd.RunE
|
||||
cmd.RunE = func(cmd *cobra.Command, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return runGroupPolicy(cmd, args, policy)
|
||||
}
|
||||
return businessRunE(cmd, args)
|
||||
}
|
||||
}
|
||||
if policy.Positionals == PositionalsReject && policy.Recovery != RecoveryDisabled {
|
||||
cmd.Args = cobra.ArbitraryArgs
|
||||
} else if policy.Positionals == PositionalsReject {
|
||||
cmd.Args = cobra.NoArgs
|
||||
}
|
||||
|
||||
if cmd.Annotations == nil {
|
||||
cmd.Annotations = map[string]string{}
|
||||
}
|
||||
cmd.Annotations[groupPolicyAnnotation] = encodeGroupPolicy(policy)
|
||||
}
|
||||
|
||||
// GroupPolicyFor reads the typed declaration compiled onto cmd. The boolean is
|
||||
// false only for a leaf. Malformed private metadata is returned as an error so
|
||||
// tree assembly can fail closed instead of silently treating a group as a leaf.
|
||||
func GroupPolicyFor(cmd *cobra.Command) (GroupPolicy, bool, error) {
|
||||
if cmd == nil || cmd.Annotations == nil {
|
||||
return GroupPolicy{}, false, nil
|
||||
}
|
||||
raw, ok := cmd.Annotations[groupPolicyAnnotation]
|
||||
if !ok {
|
||||
return GroupPolicy{}, false, nil
|
||||
}
|
||||
parts := strings.Split(raw, "|")
|
||||
if len(parts) != 3 {
|
||||
return GroupPolicy{}, false, fmt.Errorf("malformed encoded GroupPolicy %q", raw)
|
||||
}
|
||||
policy := GroupPolicy{
|
||||
Mode: GroupMode(parts[0]),
|
||||
Positionals: PositionalsPolicy(parts[1]),
|
||||
Recovery: RecoveryPolicy(parts[2]),
|
||||
}
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
return GroupPolicy{}, false, err
|
||||
}
|
||||
if policy.IsZero() {
|
||||
return GroupPolicy{}, false, fmt.Errorf("encoded GroupPolicy must not be zero")
|
||||
}
|
||||
return policy, true, nil
|
||||
}
|
||||
|
||||
func encodeGroupPolicy(policy GroupPolicy) string {
|
||||
return string(policy.Mode) + "|" + string(policy.Positionals) + "|" + string(policy.Recovery)
|
||||
}
|
||||
|
||||
func runGroupPolicy(cmd *cobra.Command, args []string, policy GroupPolicy) error {
|
||||
if len(args) == 0 {
|
||||
return cmd.Help()
|
||||
}
|
||||
input := strings.TrimSpace(args[0])
|
||||
reason := cmdutil.ClassifyCommandResolution(cmd, input)
|
||||
suggestions := cmdutil.SuggestSubcommands(cmd, input)
|
||||
if reason == cmdutil.ResolutionUnknownSubcommand && policy.Recovery == RecoveryDeep {
|
||||
if deep := cmdutil.SuggestDescendantSubcommands(cmd, input); len(deep) > 0 {
|
||||
suggestions = deep
|
||||
}
|
||||
}
|
||||
return cmdutil.NewCommandResolution(
|
||||
cmd,
|
||||
input,
|
||||
reason,
|
||||
suggestions,
|
||||
"",
|
||||
).Err()
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
// 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 (
|
||||
"errors"
|
||||
"runtime"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageValidateGroupPolicy(t *testing.T) {
|
||||
valid := []GroupPolicy{
|
||||
{},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep},
|
||||
{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDeep},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoveryDisabled},
|
||||
{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoveryDisabled},
|
||||
}
|
||||
for _, policy := range valid {
|
||||
if err := ValidateGroupPolicy(policy); err != nil {
|
||||
t.Fatalf("ValidateGroupPolicy(%+v) = %v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
policy GroupPolicy
|
||||
needle string
|
||||
}{
|
||||
{name: "partial", policy: GroupPolicy{Mode: GroupHybrid}, needle: "positionals"},
|
||||
{name: "unknown mode", policy: GroupPolicy{Mode: "leafish", Positionals: PositionalsReject, Recovery: RecoveryDisabled}, needle: "mode"},
|
||||
{name: "unknown positionals", policy: GroupPolicy{Mode: GroupHybrid, Positionals: "maybe", Recovery: RecoveryDisabled}, needle: "positionals"},
|
||||
{name: "unknown recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: "global"}, needle: "recovery"},
|
||||
{name: "navigation allows args", policy: GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsAllow, Recovery: RecoveryDisabled}, needle: "navigation-only"},
|
||||
{name: "ambiguous recovery", policy: GroupPolicy{Mode: GroupHybrid, Positionals: PositionalsAllow, Recovery: RecoverySibling}, needle: "requires recovery"},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if err := ValidateGroupPolicy(tc.policy); err == nil || !strings.Contains(err.Error(), tc.needle) {
|
||||
t.Fatalf("ValidateGroupPolicy(%+v) = %v, want %q", tc.policy, err, tc.needle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyAndReadGroupPolicy(t *testing.T) {
|
||||
policy := GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling}
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
|
||||
got, ok, err := GroupPolicyFor(cmd)
|
||||
if err != nil || !ok || got != policy {
|
||||
t.Fatalf("GroupPolicyFor() = %+v, %v, %v; want %+v, true, nil", got, ok, err, policy)
|
||||
}
|
||||
if !cmd.Runnable() || cmd.TraverseChildren {
|
||||
t.Fatalf("compiled navigation command Runnable=%v TraverseChildren=%v; policy must preserve the flag-traversal surface", cmd.Runnable(), cmd.TraverseChildren)
|
||||
}
|
||||
if cmd.Args == nil || cmd.Args(cmd, []string{"extra"}) != nil {
|
||||
t.Fatal("PositionalsReject must let command resolution inspect unmatched args")
|
||||
}
|
||||
if err := cmd.RunE(cmd, []string{"extra"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("navigation recovery error = %v", err)
|
||||
}
|
||||
var help strings.Builder
|
||||
cmd.SetOut(&help)
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("navigation help error = %v", err)
|
||||
}
|
||||
if output := help.String(); !strings.Contains(output, "Usage:") {
|
||||
t.Fatalf("navigation help output = %q", output)
|
||||
}
|
||||
// Re-applying the same declaration is idempotent.
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
|
||||
traversing := &cobra.Command{Use: "traversing", TraverseChildren: true}
|
||||
ApplyGroupPolicy(traversing, policy)
|
||||
if !traversing.TraverseChildren {
|
||||
t.Fatal("ApplyGroupPolicy changed an explicitly declared TraverseChildren surface")
|
||||
}
|
||||
|
||||
leaf := &cobra.Command{Use: "leaf"}
|
||||
if got, ok, err := GroupPolicyFor(leaf); err != nil || ok || !got.IsZero() {
|
||||
t.Fatalf("leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
annotatedLeaf := &cobra.Command{Use: "annotated-leaf", Annotations: map[string]string{"unrelated": "metadata"}}
|
||||
if got, ok, err := GroupPolicyFor(annotatedLeaf); err != nil || ok || !got.IsZero() {
|
||||
t.Fatalf("annotated leaf GroupPolicyFor() = %+v, %v, %v", got, ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyDoesNotLeakParentLocalFlags(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", SilenceUsage: true, SilenceErrors: true}
|
||||
ApplyGroupPolicy(root, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
parent := &cobra.Command{
|
||||
Use: "search",
|
||||
RunE: func(*cobra.Command, []string) error { return nil },
|
||||
}
|
||||
parent.Flags().String("dimension", "", "parent-only search dimension")
|
||||
ApplyGroupPolicy(parent, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
childCalled := false
|
||||
child := &cobra.Command{
|
||||
Use: "enterprise",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
childCalled = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
parent.AddCommand(child)
|
||||
root.AddCommand(parent)
|
||||
root.SetArgs([]string{"search", "--dimension", "name", "enterprise"})
|
||||
|
||||
err := root.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --dimension") {
|
||||
t.Fatalf("Execute() error = %v, want parent local flag rejected by child", err)
|
||||
}
|
||||
if childCalled {
|
||||
t.Fatal("parent local flag leaked into child command execution")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyHybridPreservesExecution(t *testing.T) {
|
||||
called := false
|
||||
cmd := &cobra.Command{
|
||||
Use: "hybrid",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
return nil
|
||||
},
|
||||
}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{
|
||||
Mode: GroupHybrid,
|
||||
Positionals: PositionalsAllow,
|
||||
Recovery: RecoveryDisabled,
|
||||
})
|
||||
if err := cmd.RunE(cmd, []string{"business-id"}); err != nil || !called {
|
||||
t.Fatalf("hybrid RunE was not preserved: called=%v err=%v", called, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyHybridRejectRoutesUnknownArgs(t *testing.T) {
|
||||
called := false
|
||||
wrapperFrames := 0
|
||||
cmd := &cobra.Command{
|
||||
Use: "hybrid",
|
||||
RunE: func(*cobra.Command, []string) error {
|
||||
called = true
|
||||
pcs := make([]uintptr, 32)
|
||||
frames := runtime.CallersFrames(pcs[:runtime.Callers(0, pcs)])
|
||||
for {
|
||||
frame, more := frames.Next()
|
||||
if strings.Contains(frame.Function, "corecmd.ApplyGroupPolicy.func") {
|
||||
wrapperFrames++
|
||||
}
|
||||
if !more {
|
||||
break
|
||||
}
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
policy := GroupPolicy{
|
||||
Mode: GroupHybrid,
|
||||
Positionals: PositionalsReject,
|
||||
Recovery: RecoverySibling,
|
||||
}
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
// Applying the identical declaration must be a no-op. In particular, it
|
||||
// must not wrap the already wrapped Hybrid RunE a second time.
|
||||
ApplyGroupPolicy(cmd, policy)
|
||||
if err := cmd.RunE(cmd, []string{"typo"}); err == nil || !strings.Contains(err.Error(), "unknown subcommand") {
|
||||
t.Fatalf("hybrid typo error = %v", err)
|
||||
}
|
||||
if called {
|
||||
t.Fatal("unknown positional must not reach hybrid business RunE")
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil || !called {
|
||||
t.Fatalf("hybrid empty-args execution called=%v err=%v", called, err)
|
||||
}
|
||||
if wrapperFrames != 1 {
|
||||
t.Fatalf("Hybrid RunE wrapper depth = %d, want exactly 1 after idempotent re-apply", wrapperFrames)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyDeepRecoveryUsesDescendantPath(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
sheet := &cobra.Command{Use: "sheet"}
|
||||
rangeGroup := &cobra.Command{Use: "range"}
|
||||
rangeGroup.AddCommand(&cobra.Command{Use: "read", Run: func(*cobra.Command, []string) {}})
|
||||
ApplyGroupPolicy(rangeGroup, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
sheet.AddCommand(
|
||||
rangeGroup,
|
||||
&cobra.Command{Use: "+list-sheets", Run: func(*cobra.Command, []string) {}},
|
||||
)
|
||||
ApplyGroupPolicy(sheet, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDeep,
|
||||
})
|
||||
root.AddCommand(sheet)
|
||||
|
||||
err := sheet.RunE(sheet, []string{"read"})
|
||||
var structured *apperrors.Error
|
||||
if !errors.As(err, &structured) {
|
||||
t.Fatalf("deep recovery error = %T %v", err, err)
|
||||
}
|
||||
if structured.Reason != string(cmdutil.ResolutionUnknownSubcommand) ||
|
||||
structured.Hint != `Did you mean "dws sheet range read"? (Run 'dws sheet --help' for the full list)` {
|
||||
t.Fatalf("deep recovery = %#v", structured)
|
||||
}
|
||||
if got, ok := structured.Details["suggestions"].([]string); !ok || !slices.Equal(got, []string{"range read"}) {
|
||||
t.Fatalf("deep suggestions = %#v", structured.Details["suggestions"])
|
||||
}
|
||||
|
||||
err = sheet.RunE(sheet, []string{"+list-sheet"})
|
||||
structured = nil
|
||||
if !errors.As(err, &structured) || structured.Reason != string(cmdutil.ResolutionUnknownShortcut) {
|
||||
t.Fatalf("direct shortcut recovery = %#v, err=%v", structured, err)
|
||||
}
|
||||
if !slices.Equal(structured.Actions, []string{
|
||||
"Run 'dws sheet --help' for the full list",
|
||||
"Run 'dws shortcut list --service sheet --format json'",
|
||||
}) {
|
||||
t.Fatalf("direct shortcut actions = %#v", structured.Actions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyRejectWithoutRecoveryUsesCobraArgs(t *testing.T) {
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{
|
||||
Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled,
|
||||
})
|
||||
if err := cmd.Args(cmd, []string{"extra"}); err == nil {
|
||||
t.Fatal("RecoveryDisabled must leave rejected positionals to Cobra")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageApplyGroupPolicyFailsClosed(t *testing.T) {
|
||||
mustPanic := func(name, needle string, fn func()) {
|
||||
t.Helper()
|
||||
t.Run(name, func(t *testing.T) {
|
||||
defer func() {
|
||||
got := recover()
|
||||
if got == nil || !strings.Contains(got.(string), needle) {
|
||||
t.Fatalf("panic = %v, want %q", got, needle)
|
||||
}
|
||||
}()
|
||||
fn()
|
||||
})
|
||||
}
|
||||
mustPanic("nil command", "nil command", func() { ApplyGroupPolicy(nil, GroupPolicy{}) })
|
||||
mustPanic("zero policy", "zero GroupPolicy", func() { ApplyGroupPolicy(&cobra.Command{Use: "leaf"}, GroupPolicy{}) })
|
||||
mustPanic("invalid policy", "invalid GroupPolicy", func() {
|
||||
ApplyGroupPolicy(&cobra.Command{Use: "broken"}, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: "unexpected", Recovery: RecoveryDisabled,
|
||||
})
|
||||
})
|
||||
mustPanic("hybrid must run", "must declare RunE", func() {
|
||||
ApplyGroupPolicy(&cobra.Command{Use: "hybrid"}, GroupPolicy{
|
||||
Mode: GroupHybrid, Positionals: PositionalsReject, Recovery: RecoverySibling,
|
||||
})
|
||||
})
|
||||
mustPanic("conflicting redeclaration", "redeclares GroupPolicy", func() {
|
||||
cmd := &cobra.Command{Use: "parent"}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoveryDisabled})
|
||||
})
|
||||
mustPanic("invalid existing metadata", "invalid GroupPolicy metadata", func() {
|
||||
cmd := &cobra.Command{
|
||||
Use: "broken",
|
||||
Annotations: map[string]string{
|
||||
groupPolicyAnnotation: "hybrid|reject|unexpected",
|
||||
},
|
||||
}
|
||||
ApplyGroupPolicy(cmd, GroupPolicy{Mode: GroupNavigationOnly, Positionals: PositionalsReject, Recovery: RecoverySibling})
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGroupPolicyForRejectsMalformedPrivateMetadata(t *testing.T) {
|
||||
for name, test := range map[string]struct {
|
||||
encoded string
|
||||
needle string
|
||||
}{
|
||||
"malformed": {encoded: "hybrid|reject", needle: "malformed"},
|
||||
"invalid policy": {encoded: "hybrid|reject|unexpected", needle: "recovery"},
|
||||
"zero policy": {encoded: "||", needle: "must not be zero"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
cmd := &cobra.Command{
|
||||
Use: "broken",
|
||||
Annotations: map[string]string{
|
||||
groupPolicyAnnotation: test.encoded,
|
||||
},
|
||||
}
|
||||
if _, ok, err := GroupPolicyFor(cmd); err == nil || ok || !strings.Contains(err.Error(), test.needle) {
|
||||
t.Fatalf("GroupPolicyFor(%q) = ok %v, err %v; want %q", test.encoded, ok, err, test.needle)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -151,6 +151,24 @@ func WithOperation(operation string) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// IsConfirmationRequired reports whether err (or any wrapped cause) is a
|
||||
// typed framework confirmation-gate failure carrying reason
|
||||
// confirmation_required. Downstream classifiers must pass such errors through
|
||||
// verbatim: the "re-run with --yes" semantics can only be carried by the
|
||||
// machine-readable reason, while message-text classification actively
|
||||
// misroutes them (a command path containing "permission" would be reported as
|
||||
// an auth failure, and any other wording degrades to an unclassified error).
|
||||
func IsConfirmationRequired(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
var typed *Error
|
||||
if stderrors.As(err, &typed) {
|
||||
return strings.TrimSpace(typed.Reason) == "confirmation_required"
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// WithServerKey records the server identifier associated with the failure.
|
||||
func WithServerKey(serverKey string) Option {
|
||||
return func(err *Error) {
|
||||
|
||||
@@ -15,6 +15,7 @@ package errors
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -464,3 +465,34 @@ func TestCrossPlatformCoveragePrintHumanHidesRPCCode_Normal(t *testing.T) {
|
||||
t.Fatalf("normal mode should not show RPC Code, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIsConfirmationRequired(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if IsConfirmationRequired(nil) {
|
||||
t.Fatal("nil error must not report confirmation_required")
|
||||
}
|
||||
if IsConfirmationRequired(NewValidation("missing required flag")) {
|
||||
t.Fatal("plain validation error must not report confirmation_required")
|
||||
}
|
||||
plain := stderrors.New("需要用户确认")
|
||||
if IsConfirmationRequired(plain) {
|
||||
t.Fatal("message text alone must not report confirmation_required")
|
||||
}
|
||||
confirmation := NewValidation(
|
||||
"blocked",
|
||||
WithReason("confirmation_required"),
|
||||
)
|
||||
if !IsConfirmationRequired(confirmation) {
|
||||
t.Fatal("typed confirmation error must report confirmation_required")
|
||||
}
|
||||
// 包装链(fmt.Errorf %w)必须能穿透到 typed 原因。
|
||||
wrapped := fmt.Errorf("call tool: %w", confirmation)
|
||||
if !IsConfirmationRequired(wrapped) {
|
||||
t.Fatal("wrapped confirmation error must report confirmation_required")
|
||||
}
|
||||
otherReason := NewValidation("rate limited", WithReason("rate_limit"))
|
||||
if IsConfirmationRequired(otherReason) {
|
||||
t.Fatal("other reasons must not report confirmation_required")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ var agoalLoadLocation = time.LoadLocation
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
func newAgoalCommand() *cobra.Command {
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "agoal",
|
||||
Short: "Agoal 管理",
|
||||
Long: `管理钉钉 Agoal:战略解码、经营合约、计分卡、用户目标、周月报。
|
||||
@@ -31,6 +31,7 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal scorecard detail 获取计分卡详情
|
||||
dws agoal scorecard entity-detail 获取计分卡实体详情
|
||||
dws agoal scorecard update 更新计分卡
|
||||
dws agoal scorecard search-entities 搜索计分卡指标与关键事项
|
||||
dws agoal user rules 获取用户规则
|
||||
dws agoal user objectives 查询用户目标列表
|
||||
dws agoal report list-statistics 获取周月报数据跟催列表
|
||||
@@ -38,11 +39,11 @@ func newAgoalCommand() *cobra.Command {
|
||||
dws agoal obj-template list 获取目标模板列表
|
||||
dws agoal obj-template create-or-update 新增或更新目标模板`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── strategy: 战略解码管理 ──────────────────────────────────
|
||||
|
||||
strategyCmd := &cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE}
|
||||
strategyCmd := newGroupCommand(&cobra.Command{Use: "strategy", Short: "战略解码管理", RunE: groupRunE})
|
||||
|
||||
strategyListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -138,7 +139,7 @@ scopeType 支持:
|
||||
|
||||
// ── contract: 经营合约管理 ──────────────────────────────────
|
||||
|
||||
contractCmd := &cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE}
|
||||
contractCmd := newGroupCommand(&cobra.Command{Use: "contract", Short: "经营合约管理", RunE: groupRunE})
|
||||
|
||||
contractListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -257,7 +258,7 @@ scopeType 支持:
|
||||
|
||||
// ── scorecard: 计分卡管理 ───────────────────────────────────
|
||||
|
||||
scorecardCmd := &cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE}
|
||||
scorecardCmd := newGroupCommand(&cobra.Command{Use: "scorecard", Short: "计分卡管理", RunE: groupRunE})
|
||||
|
||||
scorecardDetailCmd := &cobra.Command{
|
||||
Use: "detail",
|
||||
@@ -359,11 +360,41 @@ scopeType 支持:
|
||||
scorecardUpdateCmd.Flags().String("content", "", "内容 JSON 数组 (必填)")
|
||||
scorecardUpdateCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd)
|
||||
scorecardSearchContentCmd := &cobra.Command{
|
||||
Use: "search-entities",
|
||||
Short: "搜索计分卡指标与关键事项",
|
||||
Long: `根据关键词模糊搜索计分卡中的指标和关键事项标题,返回匹配的计分卡实体信息(计分卡ID、实体ID、实体类型、标题、所属团队等)。`,
|
||||
Example: ` dws agoal scorecard search-entities --keyword "业绩"
|
||||
dws agoal scorecard search-entities --keyword "业绩" --page 1 --page-size 20`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "keyword"); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"keyword": mustGetFlag(cmd, "keyword"),
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("request-id"); v != "" {
|
||||
toolArgs["requestId"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page"); v != 0 {
|
||||
toolArgs["page"] = v
|
||||
}
|
||||
if v, _ := cmd.Flags().GetInt("page-size"); v != 0 {
|
||||
toolArgs["pageSize"] = v
|
||||
}
|
||||
return callMCPTool("search_score_card_entities", toolArgs)
|
||||
},
|
||||
}
|
||||
scorecardSearchContentCmd.Flags().String("keyword", "", "搜索关键词,标题模糊匹配 (必填)")
|
||||
scorecardSearchContentCmd.Flags().String("request-id", "", "requestId (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page", 0, "页码,默认 1 (可选)")
|
||||
scorecardSearchContentCmd.Flags().Int("page-size", 0, "每页数量,最大 100 (可选)")
|
||||
|
||||
scorecardCmd.AddCommand(scorecardDetailCmd, scorecardEntityDetailCmd, scorecardUpdateCmd, scorecardSearchContentCmd)
|
||||
|
||||
// ── user: 用户目标管理 ──────────────────────────────────────
|
||||
|
||||
userCmd := &cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE}
|
||||
userCmd := newGroupCommand(&cobra.Command{Use: "user", Short: "用户目标管理", RunE: groupRunE})
|
||||
|
||||
userRulesCmd := &cobra.Command{
|
||||
Use: "rules",
|
||||
@@ -414,7 +445,7 @@ scopeType 支持:
|
||||
|
||||
// ── report: 周月报管理 ──────────────────────────────────────
|
||||
|
||||
reportCmd := &cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE}
|
||||
reportCmd := newGroupCommand(&cobra.Command{Use: "report", Short: "周月报管理", RunE: groupRunE})
|
||||
|
||||
reportListStatisticsCmd := &cobra.Command{
|
||||
Use: "list-statistics",
|
||||
@@ -489,7 +520,7 @@ scopeType 支持:
|
||||
|
||||
// ── template: 目标模板管理 ──────────────────────────────────
|
||||
|
||||
objTemplateCmd := &cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE}
|
||||
objTemplateCmd := newGroupCommand(&cobra.Command{Use: "obj-template", Short: "目标模板管理", RunE: groupRunE})
|
||||
|
||||
objTemplateListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
|
||||
@@ -210,6 +210,7 @@ func newAisearchCommand() *cobra.Command {
|
||||
return groupRunE(cmd, args)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(root)
|
||||
|
||||
// root 和 person 各自定义同一组本地 flag,这样:
|
||||
// - dws aisearch --query xxx ← root 自己能解析
|
||||
|
||||
+436
-23
@@ -1562,7 +1562,7 @@ func newAitableCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "aitable",
|
||||
Short: "AI 表格操作",
|
||||
Long: `管理钉钉 AI 表格:Base 管理、数据表、字段、记录、视图、表单、仪表盘、图表、导入导出。
|
||||
@@ -1586,11 +1586,11 @@ func newAitableCommand() *cobra.Command {
|
||||
dws aitable section [create|rename|delete|reorder|list-empty|list-nodes|move-node] 文件夹与节点管理`,
|
||||
RunE: groupRunE,
|
||||
SuggestionsMinimumDistance: 2, // Enable "Did you mean ...?" for typos
|
||||
}
|
||||
})
|
||||
|
||||
// ── base: Base 管理 ─────────────────────────────────────────
|
||||
|
||||
baseCmd := &cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE}
|
||||
baseCmd := newGroupCommand(&cobra.Command{Use: "base", Short: "Base 管理", RunE: groupRunE})
|
||||
|
||||
baseGetPrimaryDocIdCmd := &cobra.Command{
|
||||
Use: "get-primary-doc-id",
|
||||
@@ -1952,7 +1952,7 @@ MCP 层不会会自动解析 URL,必须直接传入 dentryUuid 以避免报错
|
||||
|
||||
// ── table: 数据表管理 ───────────────────────────────────────
|
||||
|
||||
tableCmd := &cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE}
|
||||
tableCmd := newGroupCommand(&cobra.Command{Use: "table", Short: "数据表管理", RunE: groupRunE})
|
||||
|
||||
tableGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -2223,7 +2223,7 @@ config 结构参考:
|
||||
|
||||
// ── field: 字段管理 ─────────────────────────────────────────
|
||||
|
||||
fieldCmd := &cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE}
|
||||
fieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "字段管理", RunE: groupRunE})
|
||||
|
||||
fieldGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -2582,7 +2582,7 @@ newFieldName、config、aiConfig 至少传入一项。
|
||||
|
||||
// ── record: 记录管理 ────────────────────────────────────────
|
||||
|
||||
recordCmd := &cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE}
|
||||
recordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "记录管理", RunE: groupRunE})
|
||||
|
||||
recordQueryCmd := &cobra.Command{
|
||||
Use: "query",
|
||||
@@ -3564,7 +3564,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── template: 模板搜索 ──────────────────────────────────────
|
||||
|
||||
templateCmd := &cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE}
|
||||
templateCmd := newGroupCommand(&cobra.Command{Use: "template", Short: "模板搜索", RunE: groupRunE})
|
||||
|
||||
templateSearchCmd := &cobra.Command{
|
||||
Use: "search",
|
||||
@@ -3613,7 +3613,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── attachment: 附件管理 ──────────────────────────────────────
|
||||
|
||||
attachmentCmd := &cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE}
|
||||
attachmentCmd := newGroupCommand(&cobra.Command{Use: "attachment", Short: "附件管理", RunE: groupRunE})
|
||||
|
||||
attachmentUploadCmd := &cobra.Command{
|
||||
Use: "upload",
|
||||
@@ -3690,7 +3690,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
|
||||
// ── view: 视图管理 ───────────────────────────────────────────
|
||||
|
||||
viewCmd := &cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE}
|
||||
viewCmd := newGroupCommand(&cobra.Command{Use: "view", Short: "视图管理", RunE: groupRunE})
|
||||
|
||||
viewGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -3721,6 +3721,7 @@ fieldId 必须是 primaryDoc 类型的字段。`,
|
||||
return callAitableTool("get_views", toolArgs)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(viewGetCmd)
|
||||
|
||||
// ─── view get <attr> 子命令:按属性投影 view 响应 ──────────────
|
||||
// card/timebar/aggregate 需要 viewType 校验;filter/sort/group/visible-fields/field-widths 不需要。
|
||||
@@ -4114,6 +4115,7 @@ fieldWidths 仅支持 Grid 视图。
|
||||
return callAitableTool("update_view", toolArgs)
|
||||
},
|
||||
}
|
||||
newHybridGroupCommand(viewUpdateCmd)
|
||||
|
||||
// ─── view update <attr> 子命令:按属性局部更新 ────────────────────
|
||||
|
||||
@@ -5058,9 +5060,9 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── form: 表单管理 ──────────────────────────────────────────
|
||||
|
||||
formCmd := &cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE}
|
||||
formFieldCmd := &cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE}
|
||||
formShareCmd := &cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE}
|
||||
formCmd := newGroupCommand(&cobra.Command{Use: "form", Short: "表单管理", RunE: groupRunE})
|
||||
formFieldCmd := newGroupCommand(&cobra.Command{Use: "field", Short: "表单字段管理", RunE: groupRunE})
|
||||
formShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "表单分享管理", RunE: groupRunE})
|
||||
|
||||
formListCmd := &cobra.Command{
|
||||
Use: "list",
|
||||
@@ -5312,7 +5314,7 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── form questions: 表单题目(form 视角的字段管理,等价于 field create / field delete) ──
|
||||
|
||||
formQuestionsCmd := &cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE}
|
||||
formQuestionsCmd := newGroupCommand(&cobra.Command{Use: "questions", Short: "表单题目管理(等价于 field create / delete)", RunE: groupRunE})
|
||||
|
||||
formQuestionsCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -5607,11 +5609,11 @@ locked 为 true 表示视图已锁定,false 表示未锁定。`,
|
||||
|
||||
// ── workflow: 自动化工作流管理 ────────────────────────────────
|
||||
|
||||
workflowCmd := &cobra.Command{
|
||||
workflowCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "workflow",
|
||||
Short: "自动化工作流管理(创建 / 更新 / 启停 / 执行 / 历史 / 查询)",
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
workflowCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -6066,7 +6068,7 @@ valid=false 仍表示 DSL 校验或发布未通过,必须读取 issues 修正
|
||||
|
||||
// ── dashboard: 仪表盘管理 ────────────────────────────────────
|
||||
|
||||
dashboardCmd := &cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE}
|
||||
dashboardCmd := newGroupCommand(&cobra.Command{Use: "dashboard", Short: "仪表盘管理", RunE: groupRunE})
|
||||
|
||||
dashboardConfigExampleCmd := &cobra.Command{
|
||||
Use: "config-example",
|
||||
@@ -6361,7 +6363,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── dashboard share: 仪表盘分享管理 ────────────────────────────
|
||||
|
||||
dashboardShareCmd := &cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE}
|
||||
dashboardShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "仪表盘分享管理", RunE: groupRunE})
|
||||
|
||||
dashboardShareGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -6463,7 +6465,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── chart: 图表管理 ──────────────────────────────────────────
|
||||
|
||||
chartCmd := &cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE}
|
||||
chartCmd := newGroupCommand(&cobra.Command{Use: "chart", Short: "图表管理", RunE: groupRunE})
|
||||
|
||||
chartWidgetsExampleCmd := &cobra.Command{
|
||||
Use: "widgets-example",
|
||||
@@ -6702,7 +6704,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── chart share: 图表分享管理 ────────────────────────────────
|
||||
|
||||
chartShareCmd := &cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE}
|
||||
chartShareCmd := newGroupCommand(&cobra.Command{Use: "share", Short: "图表分享管理", RunE: groupRunE})
|
||||
|
||||
chartShareGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -6806,7 +6808,7 @@ layout 数组里每项含图表的新位置(row/col/width/height)。`,
|
||||
|
||||
// ── export / import: 数据导入导出 ────────────────────────────
|
||||
|
||||
exportCmd := &cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE}
|
||||
exportCmd := newGroupCommand(&cobra.Command{Use: "export", Short: "数据导出", RunE: groupRunE})
|
||||
|
||||
exportDataCmd := &cobra.Command{
|
||||
Use: "data",
|
||||
@@ -6911,11 +6913,11 @@ export-format 可选值:excel、attachment、excel_and_attachment、excel_with
|
||||
},
|
||||
})
|
||||
|
||||
importCmd := &cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE}
|
||||
importCmd := newGroupCommand(&cobra.Command{Use: "import", Short: "数据导入", RunE: groupRunE})
|
||||
|
||||
// ── advperm: 高级权限 / 自定义角色 ────────────────────────────
|
||||
|
||||
advpermCmd := &cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE}
|
||||
advpermCmd := newGroupCommand(&cobra.Command{Use: "advperm", Short: "高级权限管理(开关 / 角色查看与删除)", RunE: groupRunE})
|
||||
|
||||
advpermEnableCmd := &cobra.Command{
|
||||
Use: "enable",
|
||||
@@ -7392,7 +7394,7 @@ role-get 自行 merge)。
|
||||
|
||||
// ── section: 文件夹与节点管理(导航树组织) ──────────────────────────────
|
||||
|
||||
sectionCmd := &cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE}
|
||||
sectionCmd := newGroupCommand(&cobra.Command{Use: "section", Short: "文件夹与节点管理", RunE: groupRunE})
|
||||
|
||||
sectionCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
@@ -8422,6 +8424,416 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
sectionMoveNodeCmd,
|
||||
)
|
||||
|
||||
// ── datasource: 数据源同步管理 ──────────────────────────────
|
||||
|
||||
datasourceCmd := newGroupCommand(&cobra.Command{Use: "datasource", Short: "数据源同步管理", RunE: groupRunE})
|
||||
|
||||
datasourceGetConfigCmd := &cobra.Command{
|
||||
Use: "get-config",
|
||||
Short: "获取数据源表同步配置",
|
||||
Example: ` dws aitable datasource get-config --base-id BASE_ID --table-id TABLE_ID`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_config", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetConfigCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_config",
|
||||
CanonicalPath: "aitable.datasource_get_config",
|
||||
CLIPath: "aitable datasource get-config",
|
||||
PrimaryCLIPath: "aitable datasource get-config",
|
||||
},
|
||||
Description: "获取数据源表的同步配置信息。",
|
||||
Interface: aitableMCPInterface("get_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取数据源表的同步配置信息。",
|
||||
UseWhen: []string{"查看已有数据源表的配置详情时"},
|
||||
AvoidWhen: []string{"更新配置用 datasource update;查询同步状态用 datasource sync-status"},
|
||||
Examples: []string{"dws aitable datasource get-config --base-id <BASE_ID> --table-id <TABLE_ID>"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetConfigCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetConfigCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
|
||||
datasourceListSourcesCmd := &cobra.Command{
|
||||
Use: "list-sources",
|
||||
Short: "列出可用数据源来源",
|
||||
Example: ` dws aitable datasource list-sources --base-id BASE_ID --datasource-type OA`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("list_datasource_sources", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceListSourcesCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_list_sources",
|
||||
CanonicalPath: "aitable.datasource_list_sources",
|
||||
CLIPath: "aitable datasource list-sources",
|
||||
PrimaryCLIPath: "aitable datasource list-sources",
|
||||
},
|
||||
Description: "列出指定 Base 下可用的数据源条目。",
|
||||
Interface: aitableMCPInterface("list_datasource_sources"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出指定 Base 下可用的数据源条目(OA 审批模板等)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可用来源时"},
|
||||
AvoidWhen: []string{"获取字段结构用 datasource get-fields"},
|
||||
Examples: []string{"dws aitable datasource list-sources --base-id <BASE_ID> --datasource-type OA"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceListSourcesCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceListSourcesCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
|
||||
validateJSONObject := func(flag, raw string) error {
|
||||
var v any
|
||||
if err := json.Unmarshal([]byte(raw), &v); err != nil {
|
||||
return fmt.Errorf("--%s must be a valid JSON object: %w", flag, err)
|
||||
}
|
||||
if _, ok := v.(map[string]any); !ok {
|
||||
return fmt.Errorf("--%s must be a JSON object, got %T", flag, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
validateAutoSyncSetting := func(raw string) error {
|
||||
return validateJSONObject("auto-sync-setting", raw)
|
||||
}
|
||||
|
||||
datasourceGetFieldsCmd := &cobra.Command{
|
||||
Use: "get-fields",
|
||||
Short: "获取数据源可同步字段列表",
|
||||
Example: ` dws aitable datasource get-fields --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return callAitableTool("get_datasource_fields", map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceGetFieldsCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_get_fields",
|
||||
CanonicalPath: "aitable.datasource_get_fields",
|
||||
CLIPath: "aitable datasource get-fields",
|
||||
PrimaryCLIPath: "aitable datasource get-fields",
|
||||
},
|
||||
Description: "获取指定数据源来源的可同步字段列表。",
|
||||
Interface: aitableMCPInterface("get_datasource_fields"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "获取指定数据源来源的可同步字段列表(字段 ID/名称/类型/是否主键)。",
|
||||
UseWhen: []string{"创建或更新数据源前需要查看可同步字段以决定 field-ids 时"},
|
||||
AvoidWhen: []string{"列出可用来源用 datasource list-sources"},
|
||||
Examples: []string{`dws aitable datasource get-fields --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceGetFieldsCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceGetFieldsCmd.Flags().String("source-config", "", "源配置 JSON 字符串,需含 processCode、name、iconUrl、url、dataType 及对应时间字段 (必填)")
|
||||
|
||||
datasourceCreateCmd := &cobra.Command{
|
||||
Use: "create",
|
||||
Short: "创建数据源表并触发首次同步",
|
||||
Example: ` dws aitable datasource create --base-id BASE_ID --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "datasource-type", "source-config"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"datasourceType": mustGetFlag(cmd, "datasource-type"),
|
||||
"sourceConfig": mustGetFlag(cmd, "source-config"),
|
||||
"auto": auto,
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("field-ids"); v != "" {
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if v, _ := cmd.Flags().GetString("auto-sync-setting"); v != "" {
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
return callAitableTool("create_datasource", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceCreateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_create",
|
||||
CanonicalPath: "aitable.datasource_create",
|
||||
CLIPath: "aitable datasource create",
|
||||
PrimaryCLIPath: "aitable datasource create",
|
||||
},
|
||||
Description: "为指定 Base 创建数据源表并触发首次全量同步。",
|
||||
Interface: aitableMCPInterface("create_datasource"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "为指定 Base 创建数据源表并触发首次全量同步,返回新建表 ID 和同步任务 ID。",
|
||||
UseWhen: []string{"需要将外部数据源接入 AI 表格、创建新的数据源表时"},
|
||||
AvoidWhen: []string{"已有数据源表改配置用 datasource update;仅触发同步用 datasource sync"},
|
||||
Examples: []string{`dws aitable datasource create --base-id <BASE_ID> --datasource-type OA --source-config '{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "datasource-type", Property: "datasourceType", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig", Required: boolPtr(true)},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceCreateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceCreateCmd.Flags().String("datasource-type", "", "数据源类型,目前支持 OA (必填)")
|
||||
datasourceCreateCmd.Flags().String("source-config", "", "源配置 JSON 字符串,须从 list-sources 原样透传 processCode/name/iconUrl/url,并设置 dataType 及对应时间字段 (必填)")
|
||||
datasourceCreateCmd.Flags().Bool("auto", false, "是否开启自动同步,默认 false;创建新数据源表时始终下发给下游")
|
||||
datasourceCreateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时同步全部字段")
|
||||
datasourceCreateCmd.Flags().String("auto-sync-setting", "", "自动同步频率配置 JSON 字符串,仅在 --auto=true 时生效。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceUpdateCmd := &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "更新数据源表同步配置并触发同步",
|
||||
Example: ` dws aitable datasource update --base-id BASE_ID --table-id TABLE_ID --auto`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
}
|
||||
if cmd.Flags().Changed("source-config") {
|
||||
if err := validateJSONObject("source-config", mustGetFlag(cmd, "source-config")); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["sourceConfig"] = mustGetFlag(cmd, "source-config")
|
||||
}
|
||||
if cmd.Flags().Changed("auto") {
|
||||
auto, _ := cmd.Flags().GetBool("auto")
|
||||
toolArgs["auto"] = auto
|
||||
}
|
||||
if cmd.Flags().Changed("field-ids") {
|
||||
v := mustGetFlag(cmd, "field-ids")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--field-ids 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
toolArgs["fieldIds"] = parseCSVValues(v)
|
||||
}
|
||||
if cmd.Flags().Changed("auto-sync-setting") {
|
||||
v := mustGetFlag(cmd, "auto-sync-setting")
|
||||
if v == "" {
|
||||
return fmt.Errorf("--auto-sync-setting 显式提供时不能为空,如需保持默认请勿传入")
|
||||
}
|
||||
if err := validateAutoSyncSetting(v); err != nil {
|
||||
return err
|
||||
}
|
||||
toolArgs["autoSyncSetting"] = v
|
||||
}
|
||||
if !cmd.Flags().Changed("source-config") && !cmd.Flags().Changed("auto") && !cmd.Flags().Changed("field-ids") && !cmd.Flags().Changed("auto-sync-setting") {
|
||||
return fmt.Errorf("至少需要一个配置变更:--source-config、--auto、--field-ids 或 --auto-sync-setting;仅触发同步请使用 datasource sync")
|
||||
}
|
||||
return callAitableTool("update_datasource_config", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceUpdateCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_update",
|
||||
CanonicalPath: "aitable.datasource_update",
|
||||
CLIPath: "aitable datasource update",
|
||||
PrimaryCLIPath: "aitable datasource update",
|
||||
},
|
||||
Description: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
Interface: aitableMCPInterface("update_datasource_config"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "更新已有数据源表的同步配置并触发一次同步。",
|
||||
UseWhen: []string{"需要修改已有数据源表的配置(更换模板、调整字段、开关自动同步)时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;仅触发同步用 datasource sync"},
|
||||
Examples: []string{
|
||||
"dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --auto",
|
||||
`dws aitable datasource update --base-id <BASE_ID> --table-id <TABLE_ID> --source-config '{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}'`,
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "source-config", Property: "sourceConfig"},
|
||||
{Name: "auto", Property: "auto"},
|
||||
{Name: "field-ids", Property: "fieldIds", InterfaceType: "array"},
|
||||
{Name: "auto-sync-setting", Property: "autoSyncSetting"},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceUpdateCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceUpdateCmd.Flags().String("source-config", "", "可选。新的源配置 JSON 字符串,不传时保持原配置;传入时整体覆盖,须含 processCode、name、iconUrl、url、dataType 及对应时间字段")
|
||||
datasourceUpdateCmd.Flags().Bool("auto", false, "可选。是否开启自动同步;仅显式设置时下发给下游,省略时保持原设置")
|
||||
datasourceUpdateCmd.Flags().String("field-ids", "", "需要同步的字段 ID 列表,逗号分隔;不传时保持现有配置(创建时默认为全部字段)")
|
||||
datasourceUpdateCmd.Flags().String("auto-sync-setting", "", "可选。自动同步频率配置 JSON 字符串,仅在显式设置 --auto=true 时生效;省略时保持原有自动同步频率配置。字段:syncType(必填,hourly/scheduled)、hourlyInterval(syncType=hourly 时必填)、scheduleType(syncType=scheduled 时必填,daily/weekly/monthly)、timeValue(HH:mm)、selectedMonthDays(scheduleType=monthly 时)、selectedWeekdays(scheduleType=weekly 时)、skipNonWorkingDay")
|
||||
|
||||
datasourceSyncCmd := &cobra.Command{
|
||||
Use: "sync",
|
||||
Short: "触发数据源表手动同步",
|
||||
Example: ` dws aitable datasource sync --base-id BASE_ID --table-ids TBL1,TBL2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableIDs := parseCSVValues(mustGetFlag(cmd, "table-ids"))
|
||||
if len(tableIDs) < 1 || len(tableIDs) > 5 {
|
||||
return fmt.Errorf("--table-ids requires 1-5 table IDs, got %d", len(tableIDs))
|
||||
}
|
||||
return callAitableTool("run_datasource_sync", map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableIds": tableIDs,
|
||||
})
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncCmd, LeafSpec{
|
||||
Safety: aitableSafetyWrite(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync",
|
||||
CanonicalPath: "aitable.datasource_sync",
|
||||
CLIPath: "aitable datasource sync",
|
||||
PrimaryCLIPath: "aitable datasource sync",
|
||||
},
|
||||
Description: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回。",
|
||||
Interface: aitableMCPInterface("run_datasource_sync"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "对已有数据源表触发手动同步(单次最多 5 张),仅触发即返回同步任务 ID。",
|
||||
UseWhen: []string{"需要手动触发已有数据源表的数据同步时"},
|
||||
AvoidWhen: []string{"创建新数据源表用 datasource create;更新配置用 datasource update"},
|
||||
Examples: []string{"dws aitable datasource sync --base-id <BASE_ID> --table-ids TBL1,TBL2"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-ids", Property: "tableIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncCmd.Flags().String("table-ids", "", "待触发同步的数据源表 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceSyncStatusCmd := &cobra.Command{
|
||||
Use: "sync-status",
|
||||
Short: "按任务 ID 查询数据源同步任务状态",
|
||||
Example: ` dws aitable datasource sync-status --base-id BASE_ID --table-id TABLE_ID --task-ids TASK1,TASK2`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if err := validateRequiredFlags(cmd, "table-id", "task-ids"); err != nil {
|
||||
return err
|
||||
}
|
||||
baseID, err := mustFlagOrFallback(cmd, "base-id", "base")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ids := parseCSVValues(mustGetFlag(cmd, "task-ids"))
|
||||
if len(ids) < 1 || len(ids) > 5 {
|
||||
return fmt.Errorf("--task-ids requires 1-5 task IDs, got %d", len(ids))
|
||||
}
|
||||
toolArgs := map[string]any{
|
||||
"baseId": baseID,
|
||||
"tableId": mustGetFlag(cmd, "table-id"),
|
||||
"taskIds": ids,
|
||||
}
|
||||
return callAitableTool("get_datasource_sync_status", toolArgs)
|
||||
},
|
||||
}
|
||||
DeclareLeafMetadata(datasourceSyncStatusCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "aitable",
|
||||
Name: "datasource_sync_status",
|
||||
CanonicalPath: "aitable.datasource_sync_status",
|
||||
CLIPath: "aitable datasource sync-status",
|
||||
PrimaryCLIPath: "aitable datasource sync-status",
|
||||
},
|
||||
Description: "按任务 ID 查询数据源同步任务状态(RUNNING/FINISHED/FAILED)。",
|
||||
Interface: aitableMCPInterface("get_datasource_sync_status"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按任务 ID 批量查询数据源同步任务状态(RUNNING/FINISHED/FAILED),与 sync/create/update 触发后配对使用。",
|
||||
UseWhen: []string{"触发同步后需要按任务 ID 查询任务是否完成时"},
|
||||
AvoidWhen: []string{"触发同步用 datasource sync"},
|
||||
Examples: []string{"dws aitable datasource sync-status --base-id <BASE_ID> --table-id <TABLE_ID> --task-ids TASK1"},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "base-id", Property: "baseId", Required: boolPtr(true)},
|
||||
{Name: "table-id", Property: "tableId", Required: boolPtr(true)},
|
||||
{Name: "task-ids", Property: "taskIds", Required: boolPtr(true)},
|
||||
},
|
||||
},
|
||||
})
|
||||
datasourceSyncStatusCmd.Flags().String("base-id", "", "Base ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("table-id", "", "数据源表 ID (必填)")
|
||||
datasourceSyncStatusCmd.Flags().String("task-ids", "", "待查询的同步任务 ID 列表,逗号分隔,1-5 个 (必填)")
|
||||
|
||||
datasourceCmd.AddCommand(
|
||||
datasourceGetConfigCmd, datasourceListSourcesCmd, datasourceGetFieldsCmd,
|
||||
datasourceCreateCmd, datasourceUpdateCmd,
|
||||
datasourceSyncCmd, datasourceSyncStatusCmd,
|
||||
)
|
||||
|
||||
// 组装 aitable 命令树
|
||||
root.AddCommand(
|
||||
baseCmd, tableCmd, fieldCmd,
|
||||
@@ -8432,6 +8844,7 @@ parentSectionId 为空串表示该节点在 Base 根目录下。
|
||||
attachmentCmd, templateCmd,
|
||||
advpermCmd,
|
||||
sectionCmd,
|
||||
datasourceCmd,
|
||||
)
|
||||
|
||||
// 批量注册 --base 作为 --base-id 的隐藏别名
|
||||
|
||||
@@ -0,0 +1,462 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package helpers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type aitableDatasourceCaller struct {
|
||||
calls []aitableTestCall
|
||||
}
|
||||
|
||||
func (c *aitableDatasourceCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.calls = append(c.calls, aitableTestCall{server: server, tool: tool, args: args})
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"status":"success","data":{"tableId":"tbl_test","taskId":"task_test"}}`,
|
||||
}}}, nil
|
||||
}
|
||||
|
||||
func (*aitableDatasourceCaller) Format() string { return "json" }
|
||||
func (*aitableDatasourceCaller) DryRun() bool { return false }
|
||||
func (*aitableDatasourceCaller) Fields() string { return "" }
|
||||
func (*aitableDatasourceCaller) JQ() string { return "" }
|
||||
|
||||
func runAitableDatasourceCommand(t *testing.T, args ...string) (*aitableDatasourceCaller, error) {
|
||||
t.Helper()
|
||||
testseam.Protect(t, &os.Args)
|
||||
|
||||
caller := &aitableDatasourceCaller{}
|
||||
InitDepsForTest(t, caller)
|
||||
deps.Out.w = io.Discard
|
||||
deps.Out.errW = io.Discard
|
||||
os.Args = append([]string{"dws", "aitable", "datasource"}, args...)
|
||||
|
||||
root := newAitableCommand()
|
||||
root.SetArgs(append([]string{"datasource"}, args...))
|
||||
return caller, root.Execute()
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsMissingTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsTooManyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5,T6")
|
||||
if err == nil || !strings.Contains(err.Error(), "1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncRejectsEmptyTableIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-ids") {
|
||||
t.Fatalf("error = %v, want table-ids error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsBoundaryFive(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1,T2,T3,T4,T5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 table-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "run_datasource_sync" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncAcceptsSingleTableID(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync", "--base-id", "BASE123",
|
||||
"--table-ids", "T1")
|
||||
if err != nil {
|
||||
t.Fatalf("single table-id should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 {
|
||||
t.Fatalf("expected 1 call, got %d", len(caller.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsTooManyTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5,TK6")
|
||||
if err == nil || !strings.Contains(err.Error(), "requires 1-5") {
|
||||
t.Fatalf("error = %v, want 1-5 limit", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusAcceptsFiveTaskIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--task-ids", "TK1,TK2,TK3,TK4,TK5")
|
||||
if err != nil {
|
||||
t.Fatalf("5 task-ids should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_sync_status" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRequiresTaskIDs(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "task-ids") {
|
||||
t.Fatalf("error = %v, want task-ids required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceSyncStatusRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "sync-status", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-config", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetConfigSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-config",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err != nil {
|
||||
t.Fatalf("get-config should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["tableId"] != "TBL456" {
|
||||
t.Fatalf("tableId = %v, want TBL456", caller.calls[0].args["tableId"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "list-sources",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err != nil {
|
||||
t.Fatalf("list-sources should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_datasource_sources" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceListSourcesRejectsMissingType(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "list-sources", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "datasource-type") {
|
||||
t.Fatalf("error = %v, want datasource-type required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("get-fields should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "get_datasource_fields" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA")
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateSuccess(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false when not provided", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithAuto(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("create with --auto should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("create with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "create_datasource" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--base-id", "BASE123", "--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"PROC-XXXX","name":"采购申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`,
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsMissingTableID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update", "--base-id", "BASE123")
|
||||
if err == nil || !strings.Contains(err.Error(), "table-id") {
|
||||
t.Fatalf("error = %v, want table-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidSourceConfig(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("error = %v, want source-config validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectSourceConfig(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "source-config") {
|
||||
t.Fatalf("source-config %q: error = %v, want source-config validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNoChanges(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456")
|
||||
if err == nil || !strings.Contains(err.Error(), "至少需要一个配置变更") {
|
||||
t.Fatalf("error = %v, want at least one config change required", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when no changes provided")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoOnly(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto only should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != true {
|
||||
t.Fatalf("auto = %v, want true", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithSourceConfig(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--source-config", `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with source-config should succeed: %v", err)
|
||||
}
|
||||
if caller.calls[0].args["sourceConfig"] != `{"processCode":"PROC-YYYY","name":"出差申请","dataType":"recent_time","recentDays":"30d","iconUrl":"https://example.com/icon.png","url":"https://example.com/oa"}` {
|
||||
t.Fatalf("sourceConfig not passed as raw string: %v", caller.calls[0].args["sourceConfig"])
|
||||
}
|
||||
if _, ok := caller.calls[0].args["auto"]; ok {
|
||||
t.Fatalf("auto should not be sent when --auto is omitted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithAutoFalse(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456", "--auto=false")
|
||||
if err != nil {
|
||||
t.Fatalf("update with --auto=false should succeed: %v", err)
|
||||
}
|
||||
if v, ok := caller.calls[0].args["auto"]; !ok || v != false {
|
||||
t.Fatalf("auto = %v, want false", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateWithFieldIDsAndAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "fldAAA,fldBBB",
|
||||
"--auto-sync-setting", `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}`)
|
||||
if err != nil {
|
||||
t.Fatalf("update with field-ids and auto-sync-setting should succeed: %v", err)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "update_datasource_config" {
|
||||
t.Fatalf("unexpected calls: %#v", caller.calls)
|
||||
}
|
||||
fieldIDs, ok := caller.calls[0].args["fieldIds"].([]string)
|
||||
if !ok || len(fieldIDs) != 2 || fieldIDs[0] != "fldAAA" || fieldIDs[1] != "fldBBB" {
|
||||
t.Fatalf("fieldIds = %v, want [fldAAA fldBBB]", caller.calls[0].args["fieldIds"])
|
||||
}
|
||||
if caller.calls[0].args["autoSyncSetting"] != `{"syncType":"scheduled","scheduleType":"daily","timeValue":"09:00"}` {
|
||||
t.Fatalf("autoSyncSetting not passed as raw string: %v", caller.calls[0].args["autoSyncSetting"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsInvalidAutoSyncSetting(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", `not-json`)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting validation error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsNonObjectAutoSyncSetting(t *testing.T) {
|
||||
cases := []string{`[]`, `"text"`, `1`, `true`, `null`}
|
||||
for _, raw := range cases {
|
||||
_, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", raw)
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("auto-sync-setting %q: error = %v, want auto-sync-setting validation error", raw, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitFieldIDs(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--field-ids", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "field-ids") {
|
||||
t.Fatalf("error = %v, want field-ids empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty field-ids is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceUpdateRejectsEmptyExplicitAutoSyncSetting(t *testing.T) {
|
||||
caller, err := runAitableDatasourceCommand(t, "update",
|
||||
"--base-id", "BASE123", "--table-id", "TBL456",
|
||||
"--auto-sync-setting", "")
|
||||
if err == nil || !strings.Contains(err.Error(), "auto-sync-setting") {
|
||||
t.Fatalf("error = %v, want auto-sync-setting empty error", err)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("MCP should not be called when empty auto-sync-setting is rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceGetFieldsRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "get-fields",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAitableDatasourceCreateRejectsMissingBaseID(t *testing.T) {
|
||||
_, err := runAitableDatasourceCommand(t, "create",
|
||||
"--datasource-type", "OA",
|
||||
"--source-config", `{"processCode":"P","name":"N","dataType":"recent_time","iconUrl":"u","url":"v"}`)
|
||||
if err == nil || !strings.Contains(err.Error(), "base-id") {
|
||||
t.Fatalf("error = %v, want base-id required", err)
|
||||
}
|
||||
}
|
||||
@@ -528,7 +528,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
},
|
||||
},
|
||||
})
|
||||
root := &cobra.Command{
|
||||
root := newGroupCommand(&cobra.Command{
|
||||
Use: "attendance",
|
||||
Short: "考勤打卡 / 排班 / 统计",
|
||||
Long: `管理钉钉考勤:查询个人考勤详情、班次查询、排班管理、获取考勤统计摘要、查询考勤组与规则。
|
||||
@@ -546,11 +546,11 @@ func newAttendanceCommand() *cobra.Command {
|
||||
globalsetting 全局规则设置项(get 查询,save 更新,仅管理员可调用,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知)
|
||||
vacation 查询当前用户假期规则列表、查询员工假期余额、查询假期余额变更记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── record ───────────────────────────────────────────────
|
||||
|
||||
attendanceRecordCmd := &cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE}
|
||||
attendanceRecordCmd := newGroupCommand(&cobra.Command{Use: "record", Short: "考勤记录", RunE: groupRunE})
|
||||
|
||||
attendanceRecordGetCmd := &cobra.Command{
|
||||
Use: "get",
|
||||
@@ -612,7 +612,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── check ────────────────────────────────────────────────
|
||||
|
||||
attendanceCheckCmd := &cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE}
|
||||
attendanceCheckCmd := newGroupCommand(&cobra.Command{Use: "check", Short: "打卡查询", RunE: groupRunE})
|
||||
|
||||
// MCP tool: query_check_result
|
||||
attendanceCheckResultCmd := &cobra.Command{
|
||||
@@ -783,7 +783,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── approve ────────────────────────────────────────────────
|
||||
|
||||
attendanceApproveCmd := &cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE}
|
||||
attendanceApproveCmd := newGroupCommand(&cobra.Command{Use: "approve", Short: "审批单查询", RunE: groupRunE})
|
||||
|
||||
// 审批类型关键词到 bizType 数字映射
|
||||
// 注意:服务端 bizType=2 同时覆盖 出差 与 外出(合并为同一类),
|
||||
@@ -992,13 +992,13 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── shift ────────────────────────────────────────────────
|
||||
|
||||
attendanceShiftCmd := &cobra.Command{
|
||||
attendanceShiftCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "shift",
|
||||
Short: "班次查询",
|
||||
Long: `查询员工班次信息(班次 = 员工当天的打卡安排)。
|
||||
返回每条记录含:用户 ID、工作日期、打卡类型(OnDuty/OffDuty)、计划打卡时间、是否休息日。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: batch_get_employee_shifts
|
||||
attendanceShiftListCmd := &cobra.Command{
|
||||
@@ -1068,7 +1068,7 @@ func newAttendanceCommand() *cobra.Command {
|
||||
|
||||
// ── class ────────────────────────────────────────────────
|
||||
|
||||
attendanceClassCmd := &cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE}
|
||||
attendanceClassCmd := newGroupCommand(&cobra.Command{Use: "class", Short: "班次规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_class_list
|
||||
attendanceClassSearchCmd := &cobra.Command{
|
||||
@@ -1415,7 +1415,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── adjustment-rule ────────────────────────────────────
|
||||
|
||||
attendanceAdjustmentCmd := &cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE}
|
||||
attendanceAdjustmentCmd := newGroupCommand(&cobra.Command{Use: "adjustment", Short: "补卡规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_adjustment_rule_detail
|
||||
attendanceAdjustmentGetCmd := &cobra.Command{
|
||||
@@ -1540,7 +1540,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── overtime-rule ──────────────────────────────────────
|
||||
|
||||
attendanceOvertimeCmd := &cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE}
|
||||
attendanceOvertimeCmd := newGroupCommand(&cobra.Command{Use: "overtime", Short: "加班规则", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_overtime_rule_detail
|
||||
attendanceOvertimeGetCmd := &cobra.Command{
|
||||
@@ -1665,7 +1665,7 @@ checkTime 字段统一使用 "HH:mm" 格式(如 "09:00"),CLI 自动转换
|
||||
|
||||
// ── group ──────────────────────────────────────────────
|
||||
|
||||
attendanceGroupCmd := &cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE}
|
||||
attendanceGroupCmd := newGroupCommand(&cobra.Command{Use: "group", Short: "考勤组", RunE: groupRunE})
|
||||
|
||||
// MCP tool: get_simple_groups
|
||||
attendanceGroupSearchCmd := &cobra.Command{
|
||||
@@ -2574,7 +2574,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
})
|
||||
|
||||
// ── selfsetting ─────────────────────────────────────────────
|
||||
attendanceSelfSettingCmd := &cobra.Command{
|
||||
attendanceSelfSettingCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "selfsetting",
|
||||
Short: "个人规则设置",
|
||||
Long: `个人规则设置相关命令。
|
||||
@@ -2583,7 +2583,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
get 查询个人规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
|
||||
save 更新保存个人规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: query_self_setting
|
||||
attendanceSelfSettingGetCmd := &cobra.Command{
|
||||
@@ -2827,7 +2827,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
})
|
||||
|
||||
// ── globalsetting ────────────────────────────────────────
|
||||
attendanceGlobalSettingCmd := &cobra.Command{
|
||||
attendanceGlobalSettingCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "globalsetting",
|
||||
Short: "全局规则设置(仅管理员)",
|
||||
Long: `全局规则设置相关命令,仅管理员可以调用。
|
||||
@@ -2836,7 +2836,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
get 查询全局规则设置,包括打卡提醒、极速打卡、打卡结果通知、缺卡提醒、个人考勤统计通知、团队考勤统计通知等设置项。
|
||||
save 更新保存全局规则设置;settingScene 必填,且对应场景至少传入一个设置字段。`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: query_global_setting
|
||||
attendanceGlobalSettingGetCmd := &cobra.Command{
|
||||
@@ -2999,7 +2999,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── report ──────────────────────────────────────────────
|
||||
|
||||
attendanceReportCmd := &cobra.Command{
|
||||
attendanceReportCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "report",
|
||||
Short: "查询考勤报表和结果",
|
||||
Long: `考勤 MCP 报表接口,仅对管理员开放
|
||||
@@ -3009,7 +3009,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
query-data 根据字段查询考勤数据
|
||||
query-leave 查询用户假期数据`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: get_report_columns
|
||||
reportColumnsCmd := &cobra.Command{
|
||||
@@ -3218,7 +3218,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── 假期 vacation ───────────────────────────────────────────────
|
||||
|
||||
attendanceVacationCmd := &cobra.Command{
|
||||
attendanceVacationCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "vacation",
|
||||
Short: "假期管理",
|
||||
Long: `管理钉钉假期:查询假期规则列表、查询员工假期余额、查询假期余额变更记录。
|
||||
@@ -3230,7 +3230,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
save-balance 更新员工假期余额
|
||||
records 查询指定员工假期余额变更记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// ── 假期规则 types ─────────────────────────────────────────
|
||||
|
||||
@@ -3771,7 +3771,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── schedule ──────────────────────────────────────────────
|
||||
|
||||
attendanceScheduleCmd := &cobra.Command{
|
||||
attendanceScheduleCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "schedule",
|
||||
Short: "排班管理",
|
||||
Long: `排班制考勤组的排班记录导入与查询(排班 = 为员工安排具体工作日期和班次)。
|
||||
@@ -3779,7 +3779,7 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
import 导入排班记录到排班制考勤组
|
||||
get 获取指定用户的排班记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// schedule import (generateTurnSchedule)
|
||||
scheduleImportCmd := &cobra.Command{
|
||||
@@ -4219,14 +4219,14 @@ statsType 统计类型支持:week(周统计)、month(月统计)。`,
|
||||
|
||||
// ── checkin ──────────────────────────────────────────────
|
||||
|
||||
checkinCmd := &cobra.Command{
|
||||
checkinCmd := newGroupCommand(&cobra.Command{
|
||||
Use: "checkin",
|
||||
Short: "签到管理",
|
||||
Long: `签到记录的查询。
|
||||
子命令:
|
||||
records 查询指定员工的签到记录`,
|
||||
RunE: groupRunE,
|
||||
}
|
||||
})
|
||||
|
||||
// MCP tool: queryUserRecordByStaffIds
|
||||
checkinRecordsCmd := &cobra.Command{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user