Compare commits
837
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9850a2e49 | ||
|
|
8097943e3a | ||
|
|
d5a9a72fa6 | ||
|
|
b7918be6f3 | ||
|
|
b84b56d9f8 | ||
|
|
e66cd95c51 | ||
|
|
7e8b216e07 | ||
|
|
5501c9f1a5 | ||
|
|
eefe6f05e1 | ||
|
|
6598292b1b | ||
|
|
dea637228d | ||
|
|
a09467f1eb | ||
|
|
34feb348af | ||
|
|
6b1a1a6201 | ||
|
|
9f60cdeef1 | ||
|
|
258caa5906 | ||
|
|
a0d59a79aa | ||
|
|
c7148f3ebb | ||
|
|
ef29b48a55 | ||
|
|
5c33ea526e | ||
|
|
6d3b54b25f | ||
|
|
867bb44586 | ||
|
|
4bbd42cc25 | ||
|
|
fd0c3350f3 | ||
|
|
2cc24de505 | ||
|
|
6575301a3a | ||
|
|
43121f1d8f | ||
|
|
cd02fe71e6 | ||
|
|
93a2071837 | ||
|
|
33730337f3 | ||
|
|
6be12655dc | ||
|
|
cf3bcb380f | ||
|
|
89e8bd7015 | ||
|
|
64e1dcc150 | ||
|
|
f3ecac1ad1 | ||
|
|
46ae1c50fe | ||
|
|
5de36d783a | ||
|
|
2bc4ded969 | ||
|
|
468f9200f6 | ||
|
|
6f61183732 | ||
|
|
332b74e8ce | ||
|
|
10bb2ec205 | ||
|
|
ac0125e468 | ||
|
|
f06a24ec2e | ||
|
|
8f135ecde6 | ||
|
|
a2e51f2b86 | ||
|
|
3d6e62fc08 | ||
|
|
c6094e291e | ||
|
|
e026c754e0 | ||
|
|
18030f1018 | ||
|
|
6297b6b0c8 | ||
|
|
e8905a1984 | ||
|
|
a097d57510 | ||
|
|
b566afe3e2 | ||
|
|
7405825294 | ||
|
|
7eb39ad5d6 | ||
|
|
eb73b944a1 | ||
|
|
ecaa375be8 | ||
|
|
dbecf23bc4 | ||
|
|
d73e199d97 | ||
|
|
7c9687094f | ||
|
|
05d8c86177 | ||
|
|
115dad3b82 | ||
|
|
b8ac9810f4 | ||
|
|
c1a90c0194 | ||
|
|
0bb2b6ce98 | ||
|
|
2662f87ad0 | ||
|
|
5a5b567eb0 | ||
|
|
c2db909bd2 | ||
|
|
871542ef0c | ||
|
|
2c7d0f3ac4 | ||
|
|
5a345228eb | ||
|
|
ea7c66b190 | ||
|
|
b2b6153424 | ||
|
|
59efb4facb | ||
|
|
3605d4f450 | ||
|
|
f34b7741d4 | ||
|
|
2c573ec892 | ||
|
|
3027337a34 | ||
|
|
d0ad33034e | ||
|
|
5f13876e6e | ||
|
|
4bb8c8b586 | ||
|
|
48e522ec00 | ||
|
|
0e0007d7b7 | ||
|
|
176b217139 | ||
|
|
5c8fd0a48c | ||
|
|
7490bb95c5 | ||
|
|
b186e59a01 | ||
|
|
a92f54df3d | ||
|
|
307c9e797b | ||
|
|
116117e987 | ||
|
|
07ce090eeb | ||
|
|
201949aec1 | ||
|
|
8eeabd1419 | ||
|
|
6035d43899 | ||
|
|
ba375b40fa | ||
|
|
0a063662a0 | ||
|
|
4148a90bf5 | ||
|
|
262248d08d | ||
|
|
e7955b5891 | ||
|
|
efb172dcd6 | ||
|
|
6572010922 | ||
|
|
0cbb1b8d30 | ||
|
|
6738d0f29e | ||
|
|
db6addfc0e | ||
|
|
384b067ead | ||
|
|
c32d10bd43 | ||
|
|
17f153a070 | ||
|
|
959bc4c1a8 | ||
|
|
9f80006b3b | ||
|
|
e530aea14d | ||
|
|
32515729af | ||
|
|
2a7ab22e85 | ||
|
|
757f45df31 | ||
|
|
ef71673c69 | ||
|
|
a3773c4384 | ||
|
|
4110330575 | ||
|
|
af0086c9a8 | ||
|
|
28f75dec0a | ||
|
|
15d5be6000 | ||
|
|
ebfba82ebc | ||
|
|
ec9897678f | ||
|
|
af6e566abd | ||
|
|
4ea298ec61 | ||
|
|
7aba24b690 | ||
|
|
60e278f32f | ||
|
|
7acbec2615 | ||
|
|
6811a12330 | ||
|
|
c54a9e1e5e | ||
|
|
a15fb22671 | ||
|
|
a1712337a8 | ||
|
|
d65ad9aa2e | ||
|
|
5d25a10223 | ||
|
|
2bd6b297b0 | ||
|
|
7688f95d2b | ||
|
|
c2ee691db7 | ||
|
|
368c879f45 | ||
|
|
1920552ab0 | ||
|
|
f1b5330a4e | ||
|
|
0d5c6d92e3 | ||
|
|
f9ccff963f | ||
|
|
5cbc11d58f | ||
|
|
49a17b4375 | ||
|
|
a0a995cfee | ||
|
|
354c4546d8 | ||
|
|
3ab22071fb | ||
|
|
a34f46794e | ||
|
|
f0b0bdbe48 | ||
|
|
1fe019994d | ||
|
|
85cb41423b | ||
|
|
b9e7ff8c55 | ||
|
|
02ccd9d134 | ||
|
|
c280b19568 | ||
|
|
bb5065410e | ||
|
|
0353215b1d | ||
|
|
b94e21331d | ||
|
|
637a6f2f68 | ||
|
|
ae9ba333a0 | ||
|
|
6dbc8399df | ||
|
|
3c7ed03bd6 | ||
|
|
24cdb85d71 | ||
|
|
a1f8ecb7f0 | ||
|
|
a34ca5a137 | ||
|
|
7887b9473f | ||
|
|
fd3c82aa91 | ||
|
|
9266632694 | ||
|
|
b0cbcd7a04 | ||
|
|
6251117bd0 | ||
|
|
566803c431 | ||
|
|
d6848da60b | ||
|
|
5f5d7ee21e | ||
|
|
53169a41af | ||
|
|
e90d5bac68 | ||
|
|
d725bdbaa3 | ||
|
|
d75b744a35 | ||
|
|
9aea8c0b5c | ||
|
|
7134f33e1d | ||
|
|
f54b964d62 | ||
|
|
88f7ea5679 | ||
|
|
11fbeb4851 | ||
|
|
2b1f38edae | ||
|
|
2f3797c1f6 | ||
|
|
c0b562cc07 | ||
|
|
37fbb110e3 | ||
|
|
7564496ea0 | ||
|
|
a0b0d98180 | ||
|
|
990c85d36b | ||
|
|
b742343937 | ||
|
|
657df05d40 | ||
|
|
86f2b14449 | ||
|
|
86014c97cf | ||
|
|
70d58648c8 | ||
|
|
94f3bba504 | ||
|
|
339eaa4b1b | ||
|
|
96774e6e23 | ||
|
|
1f77ba31f3 | ||
|
|
eb0bd69b82 | ||
|
|
665b79d8da | ||
|
|
32e7a80889 | ||
|
|
5e6b588b5e | ||
|
|
c1d90672a8 | ||
|
|
3d2d287723 | ||
|
|
969292a8d7 | ||
|
|
4bcf71fb9e | ||
|
|
ac610f2d24 | ||
|
|
98f45cfe23 | ||
|
|
fbf97ce402 | ||
|
|
f5b029b1a5 | ||
|
|
75f92cf546 | ||
|
|
6d485f47eb | ||
|
|
6651a162c5 | ||
|
|
a9aa39c3e2 | ||
|
|
545ee17316 | ||
|
|
0c62938f74 | ||
|
|
b0cd419f28 | ||
|
|
aa487002b7 | ||
|
|
941bf01e30 | ||
|
|
b439c5fa09 | ||
|
|
67250a9da5 | ||
|
|
70243acb95 | ||
|
|
6cce7fdbd8 | ||
|
|
fae21ec9f2 | ||
|
|
779fd82a88 | ||
|
|
3e60b83881 | ||
|
|
66a676b6aa | ||
|
|
23bbbccb7e | ||
|
|
868d9ff2b0 | ||
|
|
cb2f240c0c | ||
|
|
45b43e52bb | ||
|
|
d800060e06 | ||
|
|
95a5cc42ce | ||
|
|
fec750b09e | ||
|
|
8d8206f791 | ||
|
|
ddd5f15b91 | ||
|
|
15c2bd50b8 | ||
|
|
db50be868b | ||
|
|
711d557b93 | ||
|
|
a6220d7d8b | ||
|
|
6102e9fc68 | ||
|
|
81bf0d2a6b | ||
|
|
fd61868393 | ||
|
|
ae426f37de | ||
|
|
4dbfaa4cef | ||
|
|
f3a95d34a3 | ||
|
|
2ea5acc2a3 | ||
|
|
5e1bac51e5 | ||
|
|
f1e95d763d | ||
|
|
a37e6e6847 | ||
|
|
0ceb96c745 | ||
|
|
9140015c42 | ||
|
|
de418c5696 | ||
|
|
5ec5b9811c | ||
|
|
378eaa377e | ||
|
|
d4368be1c3 | ||
|
|
9733acfb5a | ||
|
|
114503d52f | ||
|
|
9de1c9c304 | ||
|
|
b1d422dcfd | ||
|
|
1231e500a3 | ||
|
|
840e1d665f | ||
|
|
f362c8c2a4 | ||
|
|
e73a1556ce | ||
|
|
c508968814 | ||
|
|
186f2fa474 | ||
|
|
d91a93c43b | ||
|
|
08254e2a36 | ||
|
|
e2c15fe9c8 | ||
|
|
9fdf0d2cb3 | ||
|
|
b6325a4f8a | ||
|
|
fdd9e189d6 | ||
|
|
65ce71b8d4 | ||
|
|
6bfcac4d54 | ||
|
|
f819566c63 | ||
|
|
10d9aa3058 | ||
|
|
eebdf52da9 | ||
|
|
a0ca1fdb28 | ||
|
|
10943629d6 | ||
|
|
9eaee76a51 | ||
|
|
2588c711a7 | ||
|
|
5a93f80daa | ||
|
|
8260cf7f53 | ||
|
|
9fd38d9b9d | ||
|
|
58dfbc5b6e | ||
|
|
000bc13450 | ||
|
|
01782cd9d7 | ||
|
|
ba56b7ff78 | ||
|
|
c5c97e60f3 | ||
|
|
6780177356 | ||
|
|
5fa40b8ada | ||
|
|
8a7d4a7027 | ||
|
|
894e550950 | ||
|
|
ad4ed41559 | ||
|
|
bb205c0f5c | ||
|
|
86f9054d5b | ||
|
|
82c6b631bf | ||
|
|
306c30ecad | ||
|
|
f793d980b5 | ||
|
|
706dbb349c | ||
|
|
d7c28bcfef | ||
|
|
45d0d1cd72 | ||
|
|
e9a2360d36 | ||
|
|
9531dad9c6 | ||
|
|
20d27f6db9 | ||
|
|
9fd9ae7a95 | ||
|
|
287b079c18 | ||
|
|
ca2b8adcb2 | ||
|
|
c4d5139a50 | ||
|
|
867f20abec | ||
|
|
7c07b29de5 | ||
|
|
6ee0df8a9c | ||
|
|
33ceab6000 | ||
|
|
26b06fe0ff | ||
|
|
a3c7009f9b | ||
|
|
2d3f820f91 | ||
|
|
50f8ade1d7 | ||
|
|
f5a1b64d7a | ||
|
|
c1f96241af | ||
|
|
eb63b3933e | ||
|
|
b87cad1eb5 | ||
|
|
5b0198b2ec | ||
|
|
0f2eec145e | ||
|
|
7a5582f4f9 | ||
|
|
eb571e6e73 | ||
|
|
54358e1195 | ||
|
|
4c5f8849d0 | ||
|
|
25a849d679 | ||
|
|
f050fbdebc | ||
|
|
4d5a47ac93 | ||
|
|
e27dc9fe53 | ||
|
|
6108f51c9d | ||
|
|
ae77915507 | ||
|
|
c494026305 | ||
|
|
6376f294da | ||
|
|
85587b9b62 | ||
|
|
f48a707e04 | ||
|
|
7cb0de1f29 | ||
|
|
10d93f310e | ||
|
|
010d100e66 | ||
|
|
32598fb38d | ||
|
|
f8d1fb84c0 | ||
|
|
22a20355e7 | ||
|
|
99478c0060 | ||
|
|
dc854acb9b | ||
|
|
ee286abb05 | ||
|
|
64c2e8544c | ||
|
|
d054e3dc01 | ||
|
|
95536c3e97 | ||
|
|
fc31fddd73 | ||
|
|
1bfedbd4d2 | ||
|
|
4298d0833b | ||
|
|
d3692e7b6e | ||
|
|
e2e855d12a | ||
|
|
31e3f6bcbc | ||
|
|
678f108adf | ||
|
|
bcb3b99faf | ||
|
|
9278a467b8 | ||
|
|
ee943d9b3f | ||
|
|
7e0957d9e8 | ||
|
|
65ad8e0562 | ||
|
|
1e14ed4a87 | ||
|
|
a3c85a01a8 | ||
|
|
082a9bb93a | ||
|
|
772bf462ee | ||
|
|
81f67c8d7b | ||
|
|
e40f5bc537 | ||
|
|
469d509cfb | ||
|
|
3210232876 | ||
|
|
ad5909b8a6 | ||
|
|
162a2eb0a7 | ||
|
|
3cce23e07d | ||
|
|
d3f62193e7 | ||
|
|
1a11c687ed | ||
|
|
dfed4ba37d | ||
|
|
f26df04679 | ||
|
|
fd6d3624c3 | ||
|
|
d02b03436d | ||
|
|
b877172d7d | ||
|
|
bc7b96ba5f | ||
|
|
28df903801 | ||
|
|
cfaf161fc7 | ||
|
|
9539c887f6 | ||
|
|
b4b4a31979 | ||
|
|
6607f44724 | ||
|
|
837a96fe3d | ||
|
|
276837caae | ||
|
|
ec7f4deaf4 | ||
|
|
e135440b98 | ||
|
|
93d7e5a4c6 | ||
|
|
fe969dad51 | ||
|
|
fdcd44f9e3 | ||
|
|
34c0c86a59 | ||
|
|
a240ad2b81 | ||
|
|
affc8715be | ||
|
|
5c7407532a | ||
|
|
b1f4a5d62a | ||
|
|
bf33ab622f | ||
|
|
843ba7d81b | ||
|
|
f39a3f5417 | ||
|
|
7afd4139fc | ||
|
|
b2cbca2762 | ||
|
|
9ce95db08e | ||
|
|
30314311e4 | ||
|
|
5b7bea8b11 | ||
|
|
e99c20a0a1 | ||
|
|
0fbdfe0130 | ||
|
|
2a8c6c87cb | ||
|
|
e806e761ad | ||
|
|
e5a47a2d18 | ||
|
|
a6696fe9e9 | ||
|
|
2e51dcf35d | ||
|
|
e54927107f | ||
|
|
06af21c7a1 | ||
|
|
eba2b692ec | ||
|
|
a5fd64a908 | ||
|
|
4262a50c16 | ||
|
|
9bc6a15232 | ||
|
|
9d1c3c5c95 | ||
|
|
ba72358f78 | ||
|
|
07fea09561 | ||
|
|
057a860dcc | ||
|
|
aeec39115f | ||
|
|
562c29905f | ||
|
|
7a945318e0 | ||
|
|
b1cefba808 | ||
|
|
96bfae079a | ||
|
|
bb18cdba3b | ||
|
|
015a1f85ca | ||
|
|
124ddd85f2 | ||
|
|
8854e0d1d4 | ||
|
|
99ca88597e | ||
|
|
017258e5b4 | ||
|
|
22862508b8 | ||
|
|
22d3dd1096 | ||
|
|
4ad8cce5f3 | ||
|
|
c53ed8d383 | ||
|
|
d10738d0db | ||
|
|
f26968264d | ||
|
|
517eedb412 | ||
|
|
6210840b54 | ||
|
|
2d76433be0 | ||
|
|
b3adfa8d26 | ||
|
|
633862d07a | ||
|
|
5459bcc524 | ||
|
|
2f31f48da0 | ||
|
|
3fb8631e5f | ||
|
|
a57e6bbfeb | ||
|
|
20a4eb77a4 | ||
|
|
e0bd2a88c0 | ||
|
|
059bdfd03e | ||
|
|
260d8ddddd | ||
|
|
c0808ab5e6 | ||
|
|
ea43db1d64 | ||
|
|
7257919a49 | ||
|
|
27f0fd4337 | ||
|
|
e4fde3917d | ||
|
|
812ec4f87e | ||
|
|
8d799979d4 | ||
|
|
2839d36631 | ||
|
|
f1eb1a44d1 | ||
|
|
6c15b591a3 | ||
|
|
d4f6aab04d | ||
|
|
9bf772ea06 | ||
|
|
a086be422a | ||
|
|
53816b3d33 | ||
|
|
0a2e49e7e4 | ||
|
|
ef39a1b8db | ||
|
|
9eccc0c9e0 | ||
|
|
dc5c9fe110 | ||
|
|
3d163242f5 | ||
|
|
2a1fe47c50 | ||
|
|
410fb05498 | ||
|
|
286a84edcd | ||
|
|
a26957c425 | ||
|
|
b8b2d9475e | ||
|
|
b7f04fd2a0 | ||
|
|
7fedbea806 | ||
|
|
6cdd781ae7 | ||
|
|
bb54761e50 | ||
|
|
3e83ac18d1 | ||
|
|
75bd1f1447 | ||
|
|
a26d219b27 | ||
|
|
46af37669c | ||
|
|
c2e4a85ba9 | ||
|
|
e29354ca70 | ||
|
|
0119f6e2a8 | ||
|
|
e98586ebb0 | ||
|
|
bd45de95ab | ||
|
|
066094a68c | ||
|
|
31cade34ff | ||
|
|
e705012011 | ||
|
|
8791088027 | ||
|
|
746b7e403c | ||
|
|
86e34b5489 | ||
|
|
029c665029 | ||
|
|
b64438d01c | ||
|
|
4943c6ff49 | ||
|
|
f0fef85905 | ||
|
|
7773eb27f1 | ||
|
|
18e32ad09d | ||
|
|
a9857d94d7 | ||
|
|
461455b4fa | ||
|
|
f0d558a0d1 | ||
|
|
3cf690e779 | ||
|
|
340f01e95c | ||
|
|
187787040b | ||
|
|
cd6e854bf1 | ||
|
|
61124f8768 | ||
|
|
6cfeac3179 | ||
|
|
acd293cc83 | ||
|
|
d2045c3441 | ||
|
|
4de41c27c7 | ||
|
|
5df2860e66 | ||
|
|
f3390b6875 | ||
|
|
621229fca2 | ||
|
|
55f25d8d48 | ||
|
|
acfbd35aa2 | ||
|
|
67fbf65916 | ||
|
|
7f82e46adf | ||
|
|
1fb1ae3e23 | ||
|
|
fd0ab16c7f | ||
|
|
95a5fd069a | ||
|
|
d6292d92d3 | ||
|
|
daaad35f5b | ||
|
|
953a36f4c9 | ||
|
|
e015f40ae2 | ||
|
|
afb9c27560 | ||
|
|
84226b963c | ||
|
|
1d1c06aaae | ||
|
|
f34f9e8223 | ||
|
|
08c9c8a923 | ||
|
|
f805c966d6 | ||
|
|
3519965285 | ||
|
|
eae7955448 | ||
|
|
ce0501b93e | ||
|
|
da93fbcc47 | ||
|
|
92f1daa95f | ||
|
|
9911d8f9c9 | ||
|
|
52324e9bc2 | ||
|
|
a80ac662f5 | ||
|
|
a5cefd67f3 | ||
|
|
8c9c22f4b0 | ||
|
|
37a05db6e9 | ||
|
|
41fdf79aea | ||
|
|
ceca98c573 | ||
|
|
c84a42b0d5 | ||
|
|
fb7696293d | ||
|
|
93d6e1a001 | ||
|
|
4d4817d74f | ||
|
|
110780bf74 | ||
|
|
26b100c6bb | ||
|
|
a54ee24acb | ||
|
|
a7074bf53f | ||
|
|
21144af79b | ||
|
|
320582f98c | ||
|
|
e04ff5a12b | ||
|
|
3bdc30badb | ||
|
|
c569def067 | ||
|
|
b82e975429 | ||
|
|
2808e71cb6 | ||
|
|
584b1bd9d4 | ||
|
|
c350311048 | ||
|
|
158e7ec701 | ||
|
|
125a101487 | ||
|
|
ec99654854 | ||
|
|
9aa76ea748 | ||
|
|
885c3fe021 | ||
|
|
d0d56cbaf5 | ||
|
|
9dbbd64f3c | ||
|
|
7ba12a8e4c | ||
|
|
dfba9546f4 | ||
|
|
82d02096f7 | ||
|
|
b3ba9fee97 | ||
|
|
41372b0597 | ||
|
|
ad08b6b499 | ||
|
|
cffc48406c | ||
|
|
250aab3ef1 | ||
|
|
e36b6dc049 | ||
|
|
f9e3476d42 | ||
|
|
d9d62fb2f7 | ||
|
|
1e04e301ea | ||
|
|
5f038d440b | ||
|
|
f9f61a4cc6 | ||
|
|
2b48f27a4b | ||
|
|
bf79a67efe | ||
|
|
8d22cd553a | ||
|
|
8936c20ef0 | ||
|
|
95d262bbb2 | ||
|
|
d5c260c7c0 | ||
|
|
4f31863aae | ||
|
|
6de2bf1518 | ||
|
|
9c297d0520 | ||
|
|
78b724480e | ||
|
|
37230d2d4d | ||
|
|
4724c30f4b | ||
|
|
fde6b59074 | ||
|
|
76b9f1536a | ||
|
|
809b9b3570 | ||
|
|
e2abc70e84 | ||
|
|
15a27a9f83 | ||
|
|
0be5b73518 | ||
|
|
a637a44b7a | ||
|
|
579eed81d9 | ||
|
|
c68d9facb2 | ||
|
|
1f9138e99a | ||
|
|
c3fd814630 | ||
|
|
5922a0717a | ||
|
|
c5bc1fdad4 | ||
|
|
9567cfd3d8 | ||
|
|
4567dd1cd6 | ||
|
|
1180510f40 | ||
|
|
75bb01bb64 | ||
|
|
2456660780 | ||
|
|
11a7ab8b7c | ||
|
|
c3dbe866c4 | ||
|
|
81f130c483 | ||
|
|
6b99685594 | ||
|
|
2e1cce8501 | ||
|
|
41e0fb381a | ||
|
|
9d59550890 | ||
|
|
870fba823b | ||
|
|
d083de5f84 | ||
|
|
1fb966dbff | ||
|
|
83f13d8e11 | ||
|
|
86bce64cc8 | ||
|
|
68e1a78810 | ||
|
|
e63a4bdf47 | ||
|
|
6ab01a365e | ||
|
|
d855edaad2 | ||
|
|
75fce5c4ff | ||
|
|
d28a50c0f4 | ||
|
|
7987fb3a35 | ||
|
|
3d6a9c6232 | ||
|
|
195569ddfa | ||
|
|
7827856876 | ||
|
|
f79f41e930 | ||
|
|
bfd53df9b2 | ||
|
|
4db117893e | ||
|
|
b314749ef7 | ||
|
|
5133103a54 | ||
|
|
9faa332306 | ||
|
|
17fa1e1b34 | ||
|
|
af1f8ccd05 | ||
|
|
c26cbbbbb8 | ||
|
|
2733f510af | ||
|
|
abc62622fb | ||
|
|
ecbd2e3009 | ||
|
|
33df6ee794 | ||
|
|
18e1c7870e | ||
|
|
bbecd2f3a6 | ||
|
|
a705c9de0b | ||
|
|
1ff4941082 | ||
|
|
f5d57c2e07 | ||
|
|
f3231ed2a8 | ||
|
|
7762abc1ae | ||
|
|
8d1b76caa7 | ||
|
|
9d0995a61d | ||
|
|
967cf26d44 | ||
|
|
571eb20457 | ||
|
|
7937d09eed | ||
|
|
bc39d24559 | ||
|
|
d31cae2b0c | ||
|
|
e998e2609d | ||
|
|
4e71f56f97 | ||
|
|
3717053d24 | ||
|
|
2a3df50d0f | ||
|
|
03b3cf68e4 | ||
|
|
bbdf843ef3 | ||
|
|
eb2658ca68 | ||
|
|
69b8df3e40 | ||
|
|
a5ac09218b | ||
|
|
8d988bc350 | ||
|
|
dc20ddecf6 | ||
|
|
d41214988a | ||
|
|
bfb812bfa0 | ||
|
|
a09790fc3f | ||
|
|
4b0f71eedc | ||
|
|
5c30d01522 | ||
|
|
c94190f90e | ||
|
|
6763ddd154 | ||
|
|
235cad4cc7 | ||
|
|
96d0d430e6 | ||
|
|
5783c4e82a | ||
|
|
baafd6fe7d | ||
|
|
23c3b74979 | ||
|
|
258e7ed872 | ||
|
|
69d813afef | ||
|
|
00305d941d | ||
|
|
ec7fdb0f0d | ||
|
|
a8e83e5e7e | ||
|
|
488d90b73c | ||
|
|
2c10be2a1a | ||
|
|
3985c4c98f | ||
|
|
196bf929c1 | ||
|
|
2dfc39f0d3 | ||
|
|
97a16c43b4 | ||
|
|
afe7d860ff | ||
|
|
63b3e72fad | ||
|
|
984529b4cb | ||
|
|
298ea5b341 | ||
|
|
3e4886fc71 | ||
|
|
72cb8f188e | ||
|
|
2f8614aa1f | ||
|
|
0e60d09980 | ||
|
|
4d182dea45 | ||
|
|
f56d3263e8 | ||
|
|
22c94900d7 | ||
|
|
0871c5d88c | ||
|
|
15a15a1c12 | ||
|
|
5c01ae845f | ||
|
|
36b58d08b0 | ||
|
|
0cc049eaa1 | ||
|
|
dd2d91ae7e | ||
|
|
98309fa239 | ||
|
|
b4e92f4e65 | ||
|
|
b34bbc9aa0 | ||
|
|
3749c6b793 | ||
|
|
40444a6f78 | ||
|
|
97248bf7c2 | ||
|
|
fd6b3be046 | ||
|
|
e2390c3385 | ||
|
|
835c9229fd | ||
|
|
ea7d8cc666 | ||
|
|
125bc88d52 | ||
|
|
d2e36a76e2 | ||
|
|
52cf261000 | ||
|
|
6b9fcf9289 | ||
|
|
8dac7d5fa5 | ||
|
|
4543e9935c | ||
|
|
e79c3ea5b9 | ||
|
|
ad2ba15a45 | ||
|
|
74350b3c7b | ||
|
|
02f66df599 | ||
|
|
883f082425 | ||
|
|
b1e7b43f85 | ||
|
|
571a096004 | ||
|
|
0d3fef0037 | ||
|
|
72934ddc39 | ||
|
|
eaf53bc2d2 | ||
|
|
3e148c6745 | ||
|
|
07bc528c6c | ||
|
|
7ee87d93ee | ||
|
|
7b77b4e615 | ||
|
|
5dcf95ce07 | ||
|
|
e70b21ae8a | ||
|
|
897eb6515b | ||
|
|
ad0582ea48 | ||
|
|
f9443af460 | ||
|
|
876afcddfb | ||
|
|
c771d48d6c | ||
|
|
9e48ef759f | ||
|
|
9fb2b76f9a | ||
|
|
228c62bc0f | ||
|
|
321514ad99 | ||
|
|
5a3617c21d | ||
|
|
0d866911e0 | ||
|
|
883f397554 | ||
|
|
2bf5401f55 | ||
|
|
c76c30a0b7 | ||
|
|
276d5bcd73 | ||
|
|
8321f1ffea | ||
|
|
888e4432a3 | ||
|
|
cb200af3b2 | ||
|
|
cf5b76de07 | ||
|
|
3832e7f5e4 | ||
|
|
71f90ee45f | ||
|
|
423e16ced0 | ||
|
|
0d175c4d53 | ||
|
|
a5a6a0f2ce | ||
|
|
c1a4bd6781 | ||
|
|
02817bc043 | ||
|
|
b08f0f77e3 | ||
|
|
6d7cc41284 | ||
|
|
9f76c1844a | ||
|
|
857d9b8c1b | ||
|
|
5bdaad092a | ||
|
|
55cf6cfb71 | ||
|
|
a7fdcea086 | ||
|
|
1bc17bc4bd | ||
|
|
9fc63b6405 | ||
|
|
3233e1fe93 | ||
|
|
f7e61feacf | ||
|
|
cdc3fbe328 | ||
|
|
b4ea1f168d | ||
|
|
b03017997d | ||
|
|
902e084d8a | ||
|
|
ccb69f93b8 | ||
|
|
412e77f215 | ||
|
|
2a0bf1ebea | ||
|
|
9ce13da6ed | ||
|
|
0e5731166b | ||
|
|
58b4d6f9f4 | ||
|
|
a3478ad587 | ||
|
|
5d1092da73 | ||
|
|
92edd8ea53 | ||
|
|
931d75beaf | ||
|
|
7667cb30a3 | ||
|
|
015daae064 | ||
|
|
e7510ea5f0 | ||
|
|
582b73cb40 | ||
|
|
46fe02f72c | ||
|
|
04ea184ff6 | ||
|
|
2dba64b880 | ||
|
|
1c9b09b23f | ||
|
|
0908b2ca6e | ||
|
|
070febd7bf | ||
|
|
e564c8d923 | ||
|
|
e3782231be | ||
|
|
167a547a65 | ||
|
|
8f62c19104 | ||
|
|
82798dc7fc | ||
|
|
3319cf62d5 | ||
|
|
1626818a98 | ||
|
|
a03d6ebacc | ||
|
|
4cded1ef6c | ||
|
|
0182060757 | ||
|
|
c9cf1cc9c1 | ||
|
|
2e7e6a1010 | ||
|
|
f88bc32259 | ||
|
|
f3cce5f49b | ||
|
|
2e389fe27d | ||
|
|
b234015e7f | ||
|
|
dd112a845e | ||
|
|
ca6e520610 | ||
|
|
b731050dad | ||
|
|
bb2dd2ba76 | ||
|
|
c0cb81c12b | ||
|
|
51dc237d8a | ||
|
|
049af9fc30 | ||
|
|
d19a15a6ed | ||
|
|
2b76047164 | ||
|
|
241444e992 | ||
|
|
52045fb290 | ||
|
|
309f833f15 | ||
|
|
115cce7308 | ||
|
|
275c3430b8 | ||
|
|
56116bf99e | ||
|
|
e85d9bc314 |
@@ -1,4 +0,0 @@
|
||||
# Default code owners for all files
|
||||
# These users will be automatically requested for review on PRs.
|
||||
|
||||
* @DingTalk-Real-AI/cli-maintainers
|
||||
@@ -3,22 +3,41 @@
|
||||
- What changed?
|
||||
- Why is this change needed?
|
||||
|
||||
## Risk tier
|
||||
|
||||
- [ ] Documentation-only: prose/assets only; no executable, generated, workflow,
|
||||
packaging, or interface behavior changed
|
||||
- [ ] Standard: ordinary implementation change with a stable package graph
|
||||
- [ ] High-risk: workflow/policy, package graph, generated Schema/registry,
|
||||
platform, auth/keychain, installer, packaging, release, transport, recovery,
|
||||
or another fail-closed infrastructure change
|
||||
|
||||
## Verification
|
||||
|
||||
For an exact in-place `CHANGELOG.md`-only pull request, the full-suite checks
|
||||
may be marked `N/A`, but the targeted CHANGELOG check is required. For every
|
||||
other pull request, mark the targeted check `N/A` and complete the applicable
|
||||
full-suite checks.
|
||||
Record the smallest targeted evidence that proves the changed behavior. Do not
|
||||
repeat the entire CI suite locally only to fill this checklist: CI expands the
|
||||
selected tier from documentation checks, through affected-package tests, to
|
||||
the complete high-risk suite.
|
||||
|
||||
- [ ] Exact `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
- [ ] Exact in-place `CHANGELOG.md`-only check (otherwise `N/A`):
|
||||
`./scripts/policy/check-changelog-pr.sh --fast-path "$(git merge-base HEAD origin/main)" HEAD`
|
||||
- [ ] `make build`
|
||||
- [ ] `make lint`
|
||||
- [ ] `make test`
|
||||
- [ ] `make policy`
|
||||
- [ ] Targeted test/check commands and results:
|
||||
- [ ] Behavior evidence (test name, CLI output shape, or before/after result):
|
||||
- [ ] Documentation links/content/rendering checked (documentation-only, otherwise
|
||||
`N/A`)
|
||||
- [ ] Full local suite run because the change is high-risk (optional for other
|
||||
tiers; record command/result or `N/A`)
|
||||
- [ ] `./scripts/policy/check-generated-drift.sh`
|
||||
(when generator inputs or generated artifacts may change)
|
||||
- [ ] `./scripts/policy/check-command-surface.sh --strict` (if command surface changed)
|
||||
- [ ] `./scripts/release/verify-package-managers.sh`
|
||||
(after `make package`, if packaging or installer surfaces changed)
|
||||
|
||||
## Notes
|
||||
|
||||
- Any risks, follow-up work, or intentional scope cuts
|
||||
|
||||
The repository automatically requests one eligible peer reviewer, including
|
||||
after a new head push when another review is needed. Once the latest push has
|
||||
peer approval and all nine required checks are current and green, auto-merge
|
||||
completes the PR; authors do not need to coordinate a separate routine merge.
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
'use strict';
|
||||
|
||||
// 评审归属是受保护分支上的声明式规则;未知路径不猜测,交给工作流负载均衡兜底。
|
||||
const REVIEWER_POOL = ['wxianfeng', 'typefield', 'haofeng0705', 'hlzjsong'];
|
||||
|
||||
const PRODUCT_GROUPS = [
|
||||
{
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
products: ['chat', 'contact', 'ding', 'event', 'mail', 'live', 'conference', 'dev', 'devapp', 'mcp', 'aiapp'],
|
||||
},
|
||||
{
|
||||
primary: 'typefield',
|
||||
backup: 'wxianfeng',
|
||||
products: ['doc', 'drive', 'wiki', 'markdown', 'docparse', 'aidesign', 'devdoc', 'blackboard', 'finance', 'law', 'credit'],
|
||||
},
|
||||
{
|
||||
primary: 'haofeng0705',
|
||||
backup: 'typefield',
|
||||
products: ['minutes', 'sheet', 'aitable', 'calendar', 'todo', 'oa', 'attendance', 'report', 'agoal', 'aisearch', 'yida', 'hrbrain'],
|
||||
},
|
||||
];
|
||||
|
||||
const pathStartsWith = (prefixes) => (path) => prefixes.some((prefix) => path.startsWith(prefix));
|
||||
|
||||
const MODULES = [
|
||||
{
|
||||
id: 'security',
|
||||
label: '登录、认证、权限、安全',
|
||||
primary: 'hlzjsong',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'internal/auth/',
|
||||
'internal/keychain/',
|
||||
'internal/audit/',
|
||||
'internal/pat/',
|
||||
'internal/security/',
|
||||
'internal/safety/',
|
||||
'pkg/edition/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'delivery',
|
||||
label: 'CI、测试、发布、安装',
|
||||
primary: 'haofeng0705',
|
||||
backup: 'wxianfeng',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
path.startsWith('.github/') ||
|
||||
path.startsWith('scripts/release/') ||
|
||||
path.startsWith('scripts/policy/') ||
|
||||
path.startsWith('scripts/dev/') ||
|
||||
path.startsWith('scripts/install') ||
|
||||
path.startsWith('Formula/') ||
|
||||
path.startsWith('build/') ||
|
||||
path.startsWith('internal/upgrade/') ||
|
||||
path.startsWith('internal/app/upgrade') ||
|
||||
path.startsWith('test/') ||
|
||||
path.startsWith('verify/') ||
|
||||
path.startsWith('.workflow/') ||
|
||||
path === 'coverage.txt' ||
|
||||
path === 'coverage-base.txt' ||
|
||||
path === '.goreleaser.yaml' ||
|
||||
path === 'package.json' ||
|
||||
path === 'package-lock.json' ||
|
||||
path === 'docs/releasing.md',
|
||||
},
|
||||
{
|
||||
id: 'architecture',
|
||||
label: 'DWS 架构、公共内核',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: pathStartsWith([
|
||||
'cmd/',
|
||||
'internal/apiclient/',
|
||||
'internal/app/',
|
||||
'internal/cli/',
|
||||
'internal/cobracmd/',
|
||||
'internal/corecmd/',
|
||||
'internal/errors/',
|
||||
'internal/executor/',
|
||||
'internal/generator/',
|
||||
'internal/i18n/',
|
||||
'internal/interfacesnapshot/',
|
||||
'internal/jsonutil/',
|
||||
'internal/localio/',
|
||||
'internal/logging/',
|
||||
'internal/output/',
|
||||
'internal/pipeline/',
|
||||
'internal/plugin/',
|
||||
'internal/profilectx/',
|
||||
'internal/registry/',
|
||||
'internal/syncdata/',
|
||||
'internal/testseam/',
|
||||
'internal/transport/',
|
||||
'pkg/',
|
||||
]),
|
||||
},
|
||||
{
|
||||
id: 'compatibility',
|
||||
label: '兼容性',
|
||||
primary: 'wxianfeng',
|
||||
backup: 'typefield',
|
||||
requiresSecondary: true,
|
||||
matches: (path) =>
|
||||
/(?:^|[/_.-])compat(?:ibility)?(?=$|[/_.-])/.test(path) ||
|
||||
path.includes('schema_compat'),
|
||||
},
|
||||
];
|
||||
|
||||
function productMatches(path, product) {
|
||||
const aliases = product === 'blackboard' ? ['blackboard', 'whiteboard'] : [product];
|
||||
return aliases.some((alias) => new RegExp(`(?:^|[/_.-])${alias}(?=$|[/_.-])`).test(path));
|
||||
}
|
||||
|
||||
const PRODUCT_MODULES = PRODUCT_GROUPS.flatMap((group) =>
|
||||
group.products.map((product) => ({
|
||||
id: `product:${product}`,
|
||||
label: `产品:${product}`,
|
||||
primary: group.primary,
|
||||
backup: group.backup,
|
||||
requiresSecondary: false,
|
||||
matches: (path) => productMatches(path, product),
|
||||
})),
|
||||
);
|
||||
|
||||
const ALL_MODULES = [MODULES[0], MODULES[1], ...PRODUCT_MODULES, MODULES[2], MODULES[3]];
|
||||
|
||||
function normalizedPaths(file) {
|
||||
return [file?.filename, file?.previous_filename]
|
||||
.filter((path) => typeof path === 'string' && path !== '')
|
||||
.map((path) => path.toLowerCase());
|
||||
}
|
||||
|
||||
function compareStats(left, right) {
|
||||
return right.files - left.files || left.module.order - right.module.order || left.module.id.localeCompare(right.module.id);
|
||||
}
|
||||
|
||||
function classifyFiles(files) {
|
||||
const counts = new Map();
|
||||
for (const file of files || []) {
|
||||
const matchingModules = new Set();
|
||||
for (const path of normalizedPaths(file)) {
|
||||
const matches = ALL_MODULES.filter((module) => module.matches(path));
|
||||
const securityOrDelivery = matches.filter(
|
||||
(module) => module.id === 'security' || module.id === 'delivery',
|
||||
);
|
||||
const effectiveMatches = securityOrDelivery.length > 0
|
||||
? [...securityOrDelivery, ...matches.filter((module) => module.id === 'compatibility')]
|
||||
: matches;
|
||||
for (const match of effectiveMatches) {
|
||||
matchingModules.add(match.id);
|
||||
}
|
||||
if (
|
||||
effectiveMatches.length === 0 &&
|
||||
(path.startsWith('internal/helpers/') || path.startsWith('internal/shortcut/'))
|
||||
) {
|
||||
matchingModules.add('architecture');
|
||||
}
|
||||
}
|
||||
for (const moduleID of matchingModules) {
|
||||
counts.set(moduleID, (counts.get(moduleID) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
return [...counts.entries()]
|
||||
.map(([id, files]) => {
|
||||
const index = ALL_MODULES.findIndex((module) => module.id === id);
|
||||
return {module: {...ALL_MODULES[index], order: index}, files};
|
||||
})
|
||||
.sort(compareStats);
|
||||
}
|
||||
|
||||
function chooseModuleReviewer(module, unavailable) {
|
||||
return [module.primary, module.backup].find(
|
||||
(reviewer) => REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer),
|
||||
);
|
||||
}
|
||||
|
||||
function addReviewer(reviewers, reviewer) {
|
||||
if (reviewer && !reviewers.includes(reviewer)) {
|
||||
reviewers.push(reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
function reviewerCandidates({preferredReviewers, fallbackReviewers, eligibleReviewers}) {
|
||||
const eligible = new Set(eligibleReviewers.map((reviewer) => reviewer.toLowerCase()));
|
||||
const candidates = [];
|
||||
for (const reviewer of [...preferredReviewers, ...fallbackReviewers]) {
|
||||
if (
|
||||
eligible.has(reviewer.toLowerCase()) &&
|
||||
!candidates.some((candidate) => candidate.toLowerCase() === reviewer.toLowerCase())
|
||||
) {
|
||||
candidates.push(reviewer);
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers,
|
||||
satisfiedReviewers = [],
|
||||
requestReviewer,
|
||||
onFailure = () => {},
|
||||
}) {
|
||||
const alreadySatisfied = new Set(
|
||||
satisfiedReviewers.map((reviewer) => reviewer.toLowerCase()),
|
||||
);
|
||||
const satisfied = new Set();
|
||||
const requested = [];
|
||||
|
||||
for (const reviewer of candidates) {
|
||||
if (satisfied.size >= requiredReviewers) {
|
||||
break;
|
||||
}
|
||||
const normalizedReviewer = reviewer.toLowerCase();
|
||||
if (alreadySatisfied.has(normalizedReviewer)) {
|
||||
satisfied.add(normalizedReviewer);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const shouldContinue = await requestReviewer(reviewer);
|
||||
if (shouldContinue === false) {
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: true};
|
||||
}
|
||||
requested.push(reviewer);
|
||||
satisfied.add(normalizedReviewer);
|
||||
} catch (error) {
|
||||
onFailure(reviewer, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {requested, satisfiedReviewers: [...satisfied], aborted: false};
|
||||
}
|
||||
|
||||
function resolveReviewRouting({files, author, latestPusher, fallbackReviewers = REVIEWER_POOL}) {
|
||||
const modules = classifyFiles(files);
|
||||
const unavailable = new Set([author, latestPusher].filter(Boolean).map((login) => login.toLowerCase()));
|
||||
const reviewers = [];
|
||||
const primaryModule = modules[0];
|
||||
|
||||
if (!primaryModule) {
|
||||
return {modules: [], reviewers, requiredReviewers: 1, reason: 'unknown_paths'};
|
||||
}
|
||||
|
||||
addReviewer(reviewers, chooseModuleReviewer(primaryModule.module, unavailable));
|
||||
|
||||
const requiresSecondary =
|
||||
modules.length > 1 || modules.some(({module}) => module.requiresSecondary);
|
||||
const secondaryModule = modules.find(({module}) => module.id !== primaryModule.module.id) || primaryModule;
|
||||
if (requiresSecondary) {
|
||||
addReviewer(
|
||||
reviewers,
|
||||
chooseModuleReviewer(secondaryModule.module, new Set([...unavailable, ...reviewers])),
|
||||
);
|
||||
}
|
||||
|
||||
for (const reviewer of fallbackReviewers) {
|
||||
if (reviewers.length >= (requiresSecondary ? 2 : 1)) {
|
||||
break;
|
||||
}
|
||||
if (REVIEWER_POOL.includes(reviewer) && !unavailable.has(reviewer)) {
|
||||
addReviewer(reviewers, reviewer);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
modules: modules.map(({module, files}) => ({id: module.id, label: module.label, files})),
|
||||
reviewers,
|
||||
requiredReviewers: requiresSecondary ? 2 : 1,
|
||||
reason: requiresSecondary ? 'cross_or_sensitive' : 'single_module',
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
REVIEWER_POOL,
|
||||
classifyFiles,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
};
|
||||
@@ -0,0 +1,148 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./reviewer-routing');
|
||||
|
||||
function route(files, author = 'author', latestPusher = author) {
|
||||
return resolveReviewRouting({files: files.map((filename) => ({filename})), author, latestPusher});
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 1);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/chat_toolbar.go', 'internal/helpers/doc_style.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['.github/workflows/ci.yml']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.equal(result.reason, 'cross_or_sensitive');
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/auth/login.go'], 'hlzjsong');
|
||||
assert.deepEqual(result.reviewers, ['typefield', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/upgrade/downloader.go']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/app/upgrade.go', 'scripts/dev/test-release.sh']);
|
||||
assert.deepEqual(result.reviewers, ['haofeng0705', 'wxianfeng']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['pkg/edition/edition.go']);
|
||||
assert.deepEqual(result.reviewers, ['hlzjsong', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/shortcut/chat/compatibility_coverage_test.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
assert.deepEqual(result.modules.map((module) => module.id), ['product:chat', 'compatibility']);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['internal/helpers/leaf_dispatch.go']);
|
||||
assert.deepEqual(result.reviewers, ['wxianfeng', 'typefield']);
|
||||
assert.equal(result.requiredReviewers, 2);
|
||||
}
|
||||
|
||||
{
|
||||
const result = route(['docs/unknown-area.md']);
|
||||
assert.deepEqual(result.reviewers, []);
|
||||
assert.equal(result.reason, 'unknown_paths');
|
||||
}
|
||||
|
||||
async function testSingleReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['wxianfeng'],
|
||||
fallbackReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
eligibleReviewers: ['wxianfeng', 'typefield', 'haofeng0705'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 1,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('cannot request primary'), {status: 422});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 1);
|
||||
}
|
||||
|
||||
async function testTwoReviewerFallback() {
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: ['haofeng0705', 'wxianfeng'],
|
||||
fallbackReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
eligibleReviewers: ['haofeng0705', 'wxianfeng', 'typefield', 'hlzjsong'],
|
||||
});
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: 2,
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
if (reviewer === 'wxianfeng') {
|
||||
throw Object.assign(new Error('temporary failure'), {status: 503});
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['haofeng0705', 'wxianfeng', 'typefield']);
|
||||
assert.deepEqual(result.requested, ['haofeng0705', 'typefield']);
|
||||
assert.equal(result.satisfiedReviewers.length, 2);
|
||||
}
|
||||
|
||||
async function testLowerPriorityExistingRequestDoesNotReplaceOwner() {
|
||||
const attempts = [];
|
||||
const result = await requestReviewersWithFallback({
|
||||
candidates: ['wxianfeng', 'typefield'],
|
||||
requiredReviewers: 1,
|
||||
satisfiedReviewers: ['typefield'],
|
||||
requestReviewer: async (reviewer) => {
|
||||
attempts.push(reviewer);
|
||||
return true;
|
||||
},
|
||||
});
|
||||
assert.deepEqual(attempts, ['wxianfeng']);
|
||||
assert.deepEqual(result.requested, ['wxianfeng']);
|
||||
assert.deepEqual(result.satisfiedReviewers, ['wxianfeng']);
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
testSingleReviewerFallback(),
|
||||
testTwoReviewerFallback(),
|
||||
testLowerPriorityExistingRequestDoesNotReplaceOwner(),
|
||||
])
|
||||
.then(() => console.log('reviewer routing policy tests passed'))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
+661
-123
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,11 @@ on:
|
||||
schedule:
|
||||
- cron: '0 18 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
sync_release_version:
|
||||
description: "Sync a specific release version's assets to Gitee (e.g. v1.0.55-beta.3)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: gitee-code-mirror
|
||||
@@ -23,7 +28,6 @@ jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.ref_name == github.event.repository.default_branch && github.repository_owner == 'DingTalk-Real-AI' }}
|
||||
# GitHub Actions 不允许在 job-level if 直接引用 secrets,故先用 env 暴露再在 step 守卫。
|
||||
env:
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
@@ -76,3 +80,42 @@ jobs:
|
||||
# main 镜像对齐;release tag 由 release.yml 单独校验后创建,禁止在这里 force。
|
||||
git push --force "$REMOTE" 'gitee-main:refs/heads/main'
|
||||
echo "✅ 已镜像 main(含 Gitee README 本地化)到 Gitee ${GITEE_REPO}"
|
||||
|
||||
- name: Download GitHub Release assets
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -eu
|
||||
echo "📥 Downloading release assets for ${VERSION}"
|
||||
mkdir -p dist
|
||||
gh release download "$VERSION" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--dir dist \
|
||||
--pattern 'dws-*' \
|
||||
--pattern 'checksums.txt' \
|
||||
--clobber
|
||||
ls -la dist/
|
||||
|
||||
- name: Verify release artifacts
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
run: |
|
||||
set -eu
|
||||
DWS_PACKAGE_DIST_DIR="$GITHUB_WORKSPACE/dist" \
|
||||
./scripts/release/verify-release-artifacts.sh "$VERSION"
|
||||
|
||||
- name: Sync release assets to Gitee
|
||||
if: ${{ inputs.sync_release_version != '' }}
|
||||
env:
|
||||
VERSION: ${{ inputs.sync_release_version }}
|
||||
GITEE_TOKEN: ${{ secrets.GITEE_TOKEN }}
|
||||
GITEE_USER: ${{ secrets.GITEE_USER }}
|
||||
GITEE_REPO: ${{ secrets.GITEE_REPO }}
|
||||
DIST_DIR: ${{ github.workspace }}/dist
|
||||
run: |
|
||||
set -eu
|
||||
echo "📦 Syncing release assets for ${VERSION} to Gitee ${GITEE_REPO}"
|
||||
./scripts/release/sync-to-gitee.sh
|
||||
|
||||
@@ -51,5 +51,6 @@ jobs:
|
||||
path: |
|
||||
.tmp-bin/multi-profile-e2e.*/out
|
||||
.tmp-bin/multi-profile-e2e.log
|
||||
include-hidden-files: true
|
||||
if-no-files-found: ignore
|
||||
retention-days: 3
|
||||
|
||||
+722
-214
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,301 @@
|
||||
name: Reviewer routing
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
branches: [main]
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
|
||||
# pull_request_target deliberately runs only this workflow from the protected
|
||||
# base branch. Never check out or execute pull-request code here.
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: reviewer-router-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
route:
|
||||
if: github.event.pull_request.draft == false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out trusted routing policy
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.base.sha }}
|
||||
persist-credentials: false
|
||||
- name: Route review and enable auto-merge
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
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 {
|
||||
REVIEWER_POOL,
|
||||
requestReviewersWithFallback,
|
||||
resolveReviewRouting,
|
||||
reviewerCandidates,
|
||||
} = require('./.github/reviewer-routing.js');
|
||||
const reviewerPool = REVIEWER_POOL;
|
||||
|
||||
async function getReadyEventPull(phase) {
|
||||
const {data: currentPull} = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
});
|
||||
if (
|
||||
currentPull.head.sha !== eventHeadSha ||
|
||||
currentPull.state !== 'open' ||
|
||||
currentPull.draft ||
|
||||
currentPull.base.ref !== 'main'
|
||||
) {
|
||||
core.info(
|
||||
`PR #${pullNumber} state or revision no longer matches this ready-main event during ${phase}; routing stopped.`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return currentPull;
|
||||
}
|
||||
const pullRequest = await getReadyEventPull('initial read');
|
||||
if (!pullRequest) {
|
||||
return;
|
||||
}
|
||||
const author = pullRequest.user.login.toLowerCase();
|
||||
const headSha = pullRequest.head.sha;
|
||||
const latestPusher =
|
||||
context.payload.action === 'synchronize'
|
||||
? context.payload.sender?.login?.toLowerCase()
|
||||
: author;
|
||||
|
||||
async function routeReview() {
|
||||
let changedFiles;
|
||||
try {
|
||||
changedFiles = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect changed files for PR #${pullNumber}; using load-balanced fallback (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
changedFiles = [];
|
||||
}
|
||||
|
||||
const eligible = reviewerPool.filter(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() !== author &&
|
||||
reviewer.toLowerCase() !== latestPusher,
|
||||
);
|
||||
if (eligible.length === 0) {
|
||||
core.warning(`No eligible reviewer remains for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existingRequestedReviewers = new Set(
|
||||
(pullRequest.requested_reviewers || []).map(({login}) => login.toLowerCase()),
|
||||
);
|
||||
|
||||
let reviews;
|
||||
try {
|
||||
reviews = await github.paginate(github.rest.pulls.listReviews, {
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not inspect existing reviews for PR #${pullNumber}; skipping reviewer routing to avoid a duplicate request (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const latestDecisionByLogin = new Map();
|
||||
for (const review of reviews) {
|
||||
const login = review.user?.login?.toLowerCase();
|
||||
if (
|
||||
!login ||
|
||||
!['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(
|
||||
review.state,
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const previous = latestDecisionByLogin.get(login);
|
||||
if (!previous || review.id > previous.id) {
|
||||
latestDecisionByLogin.set(login, review);
|
||||
}
|
||||
}
|
||||
const currentHeadReviewers = new Set(
|
||||
[...latestDecisionByLogin.values()]
|
||||
.filter(
|
||||
review =>
|
||||
review.commit_id === headSha &&
|
||||
eligible.some(
|
||||
reviewer => reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
) &&
|
||||
['APPROVED', 'CHANGES_REQUESTED'].includes(review.state),
|
||||
)
|
||||
.map(review => review.user.login.toLowerCase()),
|
||||
);
|
||||
|
||||
const loads = new Map(eligible.map(reviewer => [reviewer, 0]));
|
||||
try {
|
||||
const openPullRequests = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
for (const openPullRequest of openPullRequests) {
|
||||
for (const reviewer of openPullRequest.requested_reviewers || []) {
|
||||
const candidate = eligible.find(
|
||||
login => login.toLowerCase() === reviewer.login.toLowerCase(),
|
||||
);
|
||||
if (candidate) {
|
||||
loads.set(candidate, loads.get(candidate) + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not read current reviewer load; using deterministic rotation (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
}
|
||||
|
||||
const offset = pullNumber % eligible.length;
|
||||
const rotated = eligible.slice(offset).concat(eligible.slice(0, offset));
|
||||
const tieOrder = new Map(rotated.map((reviewer, index) => [reviewer, index]));
|
||||
const staleChangeRequester = [...latestDecisionByLogin.values()]
|
||||
.filter(review => review.state === 'CHANGES_REQUESTED')
|
||||
.sort((left, right) => right.id - left.id)
|
||||
.map(review =>
|
||||
eligible.find(
|
||||
reviewer =>
|
||||
reviewer.toLowerCase() === review.user.login.toLowerCase(),
|
||||
),
|
||||
)
|
||||
.find(Boolean);
|
||||
const ranked = [...eligible].sort(
|
||||
(left, right) =>
|
||||
Number(right === staleChangeRequester) -
|
||||
Number(left === staleChangeRequester) ||
|
||||
loads.get(left) - loads.get(right) ||
|
||||
tieOrder.get(left) - tieOrder.get(right),
|
||||
);
|
||||
|
||||
const routing = resolveReviewRouting({
|
||||
files: changedFiles,
|
||||
author,
|
||||
latestPusher,
|
||||
fallbackReviewers: ranked,
|
||||
});
|
||||
const candidates = reviewerCandidates({
|
||||
preferredReviewers: routing.reviewers,
|
||||
fallbackReviewers: ranked,
|
||||
eligibleReviewers: eligible,
|
||||
});
|
||||
const desiredReviewers = candidates.slice(0, routing.requiredReviewers);
|
||||
if (routing.reason === 'unknown_paths' && currentHeadReviewers.size > 0) {
|
||||
core.info(
|
||||
`PR #${pullNumber} has a current-head review for unknown paths; leaving manual ownership unchanged.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
core.info(
|
||||
`PR #${pullNumber} routing: ${routing.reason}; modules=${routing.modules.map(module => module.id).join(',') || 'unknown'}; reviewers=${desiredReviewers.join(',') || 'load-balanced fallback'}.`,
|
||||
);
|
||||
|
||||
const satisfiedReviewers = new Set([
|
||||
...currentHeadReviewers,
|
||||
...[...existingRequestedReviewers].filter((reviewer) =>
|
||||
candidates.some((candidate) => candidate.toLowerCase() === reviewer),
|
||||
),
|
||||
]);
|
||||
const requestResult = await requestReviewersWithFallback({
|
||||
candidates,
|
||||
requiredReviewers: routing.requiredReviewers,
|
||||
satisfiedReviewers: [...satisfiedReviewers],
|
||||
requestReviewer: async (reviewer) => {
|
||||
const currentPull = await getReadyEventPull('review request');
|
||||
if (!currentPull) {
|
||||
return false;
|
||||
}
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: pullNumber,
|
||||
reviewers: [reviewer],
|
||||
});
|
||||
core.info(
|
||||
`Requested @${reviewer} for PR #${pullNumber} (open request load: ${loads.get(reviewer)}).`,
|
||||
);
|
||||
return true;
|
||||
},
|
||||
onFailure: (reviewer, error) => {
|
||||
core.warning(
|
||||
`Could not request @${reviewer} for PR #${pullNumber}; trying the next candidate (${error.status || 'unknown status'}).`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (requestResult.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (requestResult.satisfiedReviewers.length < routing.requiredReviewers) {
|
||||
core.warning(
|
||||
`Only ${requestResult.satisfiedReviewers.length} of ${routing.requiredReviewers} required reviewers could be satisfied for PR #${pullNumber}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableAutoMerge() {
|
||||
try {
|
||||
const currentPull = await getReadyEventPull('auto-merge enable');
|
||||
if (!currentPull) {
|
||||
return;
|
||||
}
|
||||
if (currentPull.auto_merge) {
|
||||
core.info(`Auto-merge is already enabled for PR #${pullNumber}.`);
|
||||
return;
|
||||
}
|
||||
await github.graphql(
|
||||
`mutation EnableAutoMerge($pullRequestId: ID!) {
|
||||
enablePullRequestAutoMerge(
|
||||
input: {
|
||||
pullRequestId: $pullRequestId
|
||||
mergeMethod: MERGE
|
||||
}
|
||||
) {
|
||||
pullRequest {
|
||||
autoMergeRequest {
|
||||
enabledAt
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{pullRequestId: currentPull.node_id},
|
||||
);
|
||||
core.info(`Enabled native auto-merge for PR #${pullNumber}.`);
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Could not enable auto-merge for PR #${pullNumber}; checks and review can continue normally (${error.message}).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await routeReview();
|
||||
} catch (error) {
|
||||
core.warning(
|
||||
`Reviewer routing hit an unexpected error for PR #${pullNumber}; review can still proceed manually (${error.message}).`,
|
||||
);
|
||||
}
|
||||
await enableAutoMerge();
|
||||
@@ -62,3 +62,10 @@ dwsbin
|
||||
/docs/shortcut-comparison.html
|
||||
/docs/shortcut-gsb-eval.*
|
||||
/scripts/run_shortcut_real_read_matrix.py
|
||||
|
||||
# Local coverage artifacts
|
||||
coverage-shortcut.txt
|
||||
coverage-*.txt
|
||||
|
||||
# stray compiled generator binary (source lives in internal/generator/cmd_param_aliases/)
|
||||
/cmd_param_aliases
|
||||
|
||||
@@ -5,20 +5,75 @@ unrelated work, and use `gofmt` for every modified Go file.
|
||||
|
||||
## Build and test
|
||||
|
||||
- Build: `go build ./cmd`
|
||||
- Build: `make build` (wraps `scripts/dev/build.sh` → `go build -o dws ./cmd`; bare `go build ./cmd` fails because output name `cmd` collides with the directory)
|
||||
- Full test suite: `DWS_PACKAGE_VERSION=0.0.0-test go test ./...`
|
||||
- Generate Schema assets: `go generate ./internal/cli`
|
||||
- Check generated drift: `./scripts/policy/check-generated-drift.sh`
|
||||
- Param aliases generate: `go generate ./internal/cli` (entry point: `internal/cli/gen.go`; Catalog is not generated)
|
||||
- Optional diagnostic MCP dump (not a Schema pin): `make fetch-mcp-metadata` (requires `dws auth login`; writes under `artifacts/`)
|
||||
- Check generated drift + assembly determinism: `./scripts/policy/check-generated-drift.sh`
|
||||
- Check the Schema contract: `./scripts/policy/check-schema-catalog.sh`
|
||||
- Coverage-gate test naming: tests that carry coverage for the macOS platform
|
||||
gate must be named `TestCrossPlatformCoverage*` (or `TestAllShortcuts*`);
|
||||
`scripts/policy/run-platform-coverage-gate.sh` only selects those prefixes,
|
||||
so a covering test with any other name silently leaves its target uncovered.
|
||||
- Package-var injection seams (e.g. `pipelineBuildEffectiveRegistry`): swap
|
||||
them in tests only via `testseam.Swap(t, &seam, stub)` from
|
||||
`internal/testseam` — it restores the previous value through `t.Cleanup`
|
||||
structurally. Like the manual pattern it replaces, Swap mutates global state
|
||||
and is **not** safe for `t.Parallel` tests.
|
||||
- Cross-package test helpers (e.g. `StoreProductDeclRawForTest`) live in
|
||||
per-package `fortest.go` files, never scattered through production files;
|
||||
the `ForTest` suffix is the boundary and production code must not call them.
|
||||
|
||||
Generated Schema JSON is committed. Change its source inputs and generators,
|
||||
then regenerate; do not hand-edit generated Catalog or Agent metadata files.
|
||||
`internal/cli/schema_command_registry.json` is different: it is a reviewed
|
||||
`CommandRegistry` source, not a generated snapshot. It is the single reviewed
|
||||
source of stable canonical identity,
|
||||
primary paths, aliases, and navigation. Edit it only when reviewed exposure,
|
||||
identity, primary path, or aliases change; parameter, Skill, and metadata-only
|
||||
changes must not rewrite it mechanically.
|
||||
Schema Catalog delivery is **声明即 Catalog**: production assembles via
|
||||
`RegisterSchemaSourceRoot` → `ResolveSchemaBuild` (factory registered in
|
||||
`internal/app`). There is no
|
||||
`cmd_schema_catalog` `//go:generate` delivery step. `dws schema -f json` remains
|
||||
the wire projection. `cmd_schema_catalog` produces CI/local dumps only;
|
||||
`internal/cli/schema_catalog/`, `internal/cli/schema_meta_index.gob`, and
|
||||
`internal/cli/schema_meta_index.json` must not be committed. `schema_agent_metadata/` is retired: if that directory
|
||||
(or `schema_agent_metadata_audit.json`) is present, policy fails.
|
||||
Command identity is no longer a file input: it is collected from
|
||||
`ContractFinal.Identity` on the live Cobra leaves
|
||||
(`internal/cli/schema_identity_collect.go` → `BuildEffectiveCommandRegistry`).
|
||||
The reviewed `schema_command_registry/` was retired together with that
|
||||
switchover and must not reappear; identity changes happen by editing the leaf
|
||||
declaration. The remaining **reviewed inputs** under `internal/cli` (see Agent
|
||||
Schema contract) keep separate authorities — do not merge them with
|
||||
`param_concepts.json` or promote any of them into Catalog declaration.
|
||||
|
||||
## Command framework declaration
|
||||
|
||||
- Framework definition: `docs/rfc-command-framework-convergence.md` **§5.0**
|
||||
- Today: `helpers.LeafSpec` / `shortcut.Shortcut` → `corecmd.Spec` (+ optional `Contract`) → `corecmd.New`
|
||||
- **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.
|
||||
- Package seam:
|
||||
- types / ProductDecl → `corecmd/contract` (DTO only; **no** Cobra-keyed ContractFinal store)
|
||||
- AnnotateRuntime* writers → `internal/corecmd/runtimeannotate` (framework-owned)
|
||||
- ContractFinal cobra store + Register → `internal/corecmd/contractfinal` (framework-owned)
|
||||
- homology gates → `internal/cli/homology`
|
||||
- Catalog assembly / `ResolveMeta` (`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`); go:embed only for reviewed inputs → `internal/cli` root (package-local aliases for annotate/store APIs live in `runtime_schema_seam.go`; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed — import `corecmd/*` directly)
|
||||
- **Hard rule**: `internal/corecmd` (and its subpackages) must **not** import any `internal/cli` package
|
||||
- Authoring tiers (current, not aspirational):
|
||||
- **Tier1** — `corecmd.New` / `helpers.NewLeafCommand` (fully managed declare + execute)
|
||||
- **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.
|
||||
- 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
|
||||
- **Do not add** new production `AnnotateRuntimeRisk` / `AnnotateRuntimeGate`
|
||||
(`runtime_gate`) call sites; migrate leaves to declared `Safety` /
|
||||
`ContractDecl` instead. Existing annotate sites may remain until migrated.
|
||||
|
||||
## flag / help / schema homology
|
||||
|
||||
- Decision (path A — Contract/LeafSpec is CLI-surface authority **and must embed into Schema**): `docs/flag-help-schema-homology.md`
|
||||
- Hard rule: every help/Schema fact is **declared** **or** **annotated**; never inference-only (§1.1–§1.3; framework §5.0).
|
||||
- Embed path: `corecmd.New` → `dws.schema.*` annotations → Schema catalog assembly
|
||||
- MCP metadata must not create CLI flags; optional 1:1 passthrough is a gated subset only.
|
||||
- Gate IDs: `HOM-P*`, `HOM-S*`, `HOM-I1`, `HOM-D1` (see that doc §3–§4). `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` are on the `check-schema-catalog.sh` policy whitelist; remaining IDs land incrementally.
|
||||
|
||||
## Agent Schema contract
|
||||
|
||||
@@ -27,72 +82,133 @@ The Schema data flow is one way:
|
||||
```text
|
||||
1. app.NewRootCommand()
|
||||
└─ builds the real Cobra command tree and flags
|
||||
└─ leaf Safety / Contract / contract.ParamDecl declare ContractFinal (declare-or-annotate)
|
||||
|
||||
2. schema_command_registry.json
|
||||
+ schema_hints/metadata/<product>.json tool parameters (+ cli_path)
|
||||
2. CollectIdentitySpecs (ContractFinal.Identity on live Cobra leaves)
|
||||
└─ forms EffectiveCommandRegistry
|
||||
└─ binds exactly to real Cobra leaves and aliases
|
||||
|
||||
3. Parameter resolution
|
||||
Cobra flags
|
||||
+ schema_parameter_bindings.json
|
||||
+ metadata tool parameters
|
||||
+ contract.ParamDecl.Property / native annotations (primary property authority)
|
||||
+ schema_parameter_mapping_ledger.go (mapping_exclusions / removals only;
|
||||
active bindings JSON retired after Track 1 Phase 2)
|
||||
└─ produces ParameterSpec and constraints
|
||||
|
||||
4. Agent and interface semantics
|
||||
schema_hints/selection/<product>.json (selection prose)
|
||||
+ schema_hints/metadata/<product>.json (safety/interface/runtime_gate)
|
||||
+ pinned MCP metadata
|
||||
ProductDecl + leaf ContractFinal Selection / Safety / Interface
|
||||
+ contract.ParamDecl (interface_type / property)
|
||||
└─ resolves Agent metadata by source precedence
|
||||
Markdown is evidence only; it is not concatenated into final prose
|
||||
└─ schema_hints/ and schema_mcp_metadata.json are fully retired
|
||||
|
||||
5. One typed hub
|
||||
5. One typed hub
|
||||
BoundCommandRegistry
|
||||
+ ParameterSpec
|
||||
+ Agent metadata
|
||||
+ Interface metadata
|
||||
└─ resolves every command exactly once into ToolSpec
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ aggregates SchemaRegistry + SchemaIndex
|
||||
└─ ResolveSchemaBuild assembles at runtime; deliverySchemaCatalog wraps it (lazy, sync.Once)
|
||||
|
||||
6. One-way publication
|
||||
6. Runtime delivery (no generate-written Catalog authority)
|
||||
SchemaRegistry
|
||||
└─ internal/cli/schema_catalog.json
|
||||
└─ dws schema list/product/group/leaf/--all
|
||||
└─ dws schema list/product/group/leaf/--all (-f json wire)
|
||||
└─ ResolveMeta projects Identity/Safety/Selection from the same registry
|
||||
└─ CI may dump Catalog via cmd_schema_catalog for jq gates / determinism
|
||||
```
|
||||
|
||||
Parameter overlays from metadata are merged into `EffectiveCommandRegistry`
|
||||
*before* Cobra binding; after that point there is no second identity source and
|
||||
no identity precedence winner. The binder must reject a missing/non-runnable
|
||||
Cobra path, an alias collision, and any native identity annotation that
|
||||
disagrees with the effective registry. A missing native identity annotation is
|
||||
allowed because annotations are implementation-side assertions, not identity
|
||||
fallbacks.
|
||||
**Reviewed inputs / 评审输入** (organizational family under `internal/cli`;
|
||||
parallel peers, not one merged authority). These are assembly inputs only —
|
||||
never Catalog declaration authority, never leaf `Contract` / `ProductDecl`
|
||||
substitutes. Keep them side-by-side; do **not** fold one into another:
|
||||
|
||||
| Input | Path | Owns |
|
||||
|---|---|---|
|
||||
| Command identity | collected from `ContractFinal.Identity` on live Cobra leaves (`schema_identity_collect.go`; not a file input) | stable identity, primary CLI path, aliases, navigation |
|
||||
| Param concepts | `param_concepts.json` (+ `.schema.json`) | argv synonym / concept dictionary (reduced to `param_aliases_generated.go`) |
|
||||
| Exclusions | `schema_command_exclusions.go` | exact reviewed CLI paths excluded from Schema (non-empty reason) |
|
||||
| Mapping ledger | `schema_parameter_mapping_ledger.go` | `mapping_exclusions` / removals (CLI flags with no direct RPC property); active bindings JSON retired |
|
||||
|
||||
`schema_mcp_metadata.json` is retired and must not reappear. Interface facts
|
||||
(`interface_ref`, `interface_type`, …) declare on leaf `Contract` /
|
||||
`contract.ParamDecl`. Retiring the pin cleared MCP-sourced `interface_type`
|
||||
values from the wire; schema-compat deliberately accepts clearing (missing =
|
||||
unknown for consumers) while still rejecting any change to a different
|
||||
non-empty value. Re-populating a value requires an explicit `ParamDecl`
|
||||
declaration, not a new pin.
|
||||
|
||||
**Aliases are three distinct layers** (do not conflate):
|
||||
|
||||
| Layer | Owns |
|
||||
|---|---|
|
||||
| `FlagSpec.Aliases` / Cobra flag aliases | executable flag synonyms on a leaf |
|
||||
| `ContractFinal.Identity` `aliases` | reviewed CLI-path aliases for the same command identity |
|
||||
| `param_concepts.json` | argv synonym / concept dictionary (central preparse normalization) |
|
||||
|
||||
**Visibility vs exclusions:** collected identity `visibility` is dormant (all
|
||||
entries default `public`); “runnable but not Agent-visible” belongs in
|
||||
`schema_command_exclusions.go`, not new `visibility` values. Native identity
|
||||
annotations are consistency assertions only — they must agree with the
|
||||
collected identity and never materialize or override it.
|
||||
|
||||
Leaf declare (`Contract` / `ParamDecl` / `Safety` / `ProductDecl`) and the live
|
||||
Cobra tree remain separate from this table: declare owns semantics; Cobra owns
|
||||
executability and flags.
|
||||
|
||||
After binding there is no second identity source and no identity precedence
|
||||
winner. The binder must reject a missing/non-runnable Cobra path, an alias
|
||||
collision, and any native identity annotation that disagrees with the effective
|
||||
registry. A missing native identity annotation is allowed because annotations
|
||||
are implementation-side assertions, not identity fallbacks.
|
||||
|
||||
The assembler resolves every bound command exactly once into one `ToolSpec`.
|
||||
Build-time gates and the snapshot serializer consume that source-resolved typed
|
||||
registry/index. Runtime projections and delivery gates consume the typed
|
||||
registry/index returned by the production snapshot loader. Neither path may
|
||||
reopen annotations, merge source records, or use a previous Catalog or other
|
||||
generated JSON as a source. `schema_catalog.json` is output-only in the
|
||||
generation graph. The production loader decoding the embedded published
|
||||
snapshot is a delivery boundary, not source resolution; it must never create or
|
||||
repair a Cobra command, flag, registry entry, or later Catalog generation.
|
||||
CI determinism (`check-schema-assembly.sh`) and policy jq gates consume a
|
||||
fresh assembly dump; runtime consumes the same `ResolveSchemaBuild` path via
|
||||
`RegisterSchemaSourceRoot`. Neither path may reopen annotations, merge source
|
||||
records, or use a previous Catalog JSON as a source.
|
||||
|
||||
### Assembly vs consumption
|
||||
|
||||
**Assembly** (declare → typed registry; CI + runtime):
|
||||
- Runtime entry: `RegisterSchemaSourceRoot` (`internal/app`) →
|
||||
`ResolveSchemaBuild` / `deliverySchemaCatalog` (lazy, sync.Once).
|
||||
- CI tool: `cmd_schema_catalog` dumps an assembled Catalog for jq/determinism;
|
||||
it is **not** a `//go:generate` or committed delivery step.
|
||||
- `gen.go` only generates `param_aliases_generated.go`.
|
||||
- Inputs: **reviewed inputs** (param_concepts / exclusions / mapping ledger —
|
||||
see table above) + ProductDecl/ContractFinal (identity is collected from
|
||||
`ContractFinal.Identity`) + live Cobra tree.
|
||||
`schema_hints/`, `schema_agent_metadata/`, `schema_command_registry/`, and
|
||||
`schema_mcp_metadata.json` must not reappear.
|
||||
- Gates: `make generate-schema` (param aliases + assembly determinism),
|
||||
`check-generated-drift.sh`, `check-schema-catalog.sh`.
|
||||
|
||||
**Consumption** (runtime, unified API):
|
||||
- Entry point: `ResolveMeta(cliPath) → CommandMeta{Identity, Safety, Selection}`
|
||||
in `internal/cli/command_meta.go` — projected from the assembled registry
|
||||
when the app factory is registered.
|
||||
- Consumers: `--help` (Safety annotation via `RenderSafetyAnnotation`),
|
||||
agent selection, future skill generation; `dws schema` uses the same
|
||||
assembled Catalog (`-f json` wire unchanged).
|
||||
- `SafetyForCLIPath` delegates to `ResolveMeta` (backward compatible).
|
||||
|
||||
This split is architecturally isomorphic to Lark's typed metadata registry,
|
||||
navigation catalog, and schema renderer. DWS intentionally preserves its
|
||||
existing flat JSON wire contract for compatibility; do not treat architectural
|
||||
alignment as permission to make an unversioned wire-format change.
|
||||
|
||||
The reviewed `CommandRegistry` is the sole source of stable command identity
|
||||
and navigation. The executable Cobra tree remains the source of truth for
|
||||
whether a CLI path exists, is runnable, and which flags it accepts. Schema
|
||||
coverage is bidirectional:
|
||||
The identity collected from `ContractFinal.Identity` (via
|
||||
`CollectIdentitySpecs`) is the sole source of stable command identity and
|
||||
navigation. The executable Cobra tree remains the source of truth for whether
|
||||
a CLI path exists, is runnable, and which flags it accepts. Schema coverage is
|
||||
bidirectional:
|
||||
|
||||
1. Every final `SchemaRegistry` tool, including its serialized Catalog
|
||||
projection, must resolve to an executable Cobra command.
|
||||
2. Every public runnable Cobra leaf must either resolve to Schema or appear as
|
||||
an exact, reviewed exclusion with a non-empty reason in
|
||||
`internal/cli/schema_command_exclusions.json`.
|
||||
`internal/cli/schema_command_exclusions.go` (central Go groups; not JSON).
|
||||
|
||||
Do not use prefix or wildcard exclusions: they can silently hide future
|
||||
commands. Remove an exclusion when its command enters Schema; stale, invalid,
|
||||
@@ -100,114 +216,117 @@ or duplicate exclusions must fail generation and CI.
|
||||
|
||||
When adding or changing an Agent-visible command, review all relevant inputs:
|
||||
|
||||
- `internal/cli/schema_command_registry.json` for the reviewed
|
||||
`CommandRegistry`: canonical identity, primary CLI path, aliases, and stable
|
||||
navigation. It is the identity source and is not a generated artifact.
|
||||
- `internal/cli/schema_command_registry.schema.json` is its closed,
|
||||
machine-readable editing contract. Preserve the local `$schema` reference;
|
||||
unknown fields, invalid visibility values, stale paths, and collisions fail
|
||||
Go validation and policy.
|
||||
- `internal/cli/schema_hints/metadata/<product>.json` for safety, interface,
|
||||
`runtime_gate`, and optional parameter overlays (`parameters` / `cli_path`).
|
||||
- `internal/cli/schema_hints/selection/<product>.json` for reviewed Agent
|
||||
selection prose (`agent_summary`, `use_when`, `avoid_when`, `examples`).
|
||||
- `internal/cli/schema_hints/index.json` only maps product IDs to those files.
|
||||
- Leaf `ContractFinal.Identity` for canonical identity, primary CLI path,
|
||||
aliases, and stable navigation. Identity is collected from the live Cobra
|
||||
leaves (`CollectIdentitySpecs`); there is no separate identity file. Invalid
|
||||
canonical paths, alias collisions, stale paths, and drift fail collection,
|
||||
binding, and policy.
|
||||
- Leaf `Safety` / `Contract` (`corecmd.ContractDecl`) / `contract.ParamDecl`
|
||||
(helpers `LeafSpec` or shortcut `Contract`) for parameter facts, interface
|
||||
disposition, safety, and Agent selection prose. Delivered provenance is
|
||||
`contract_final` from `corecmd.contract` (description may stamp `cobra_help`
|
||||
when Cobra Long wins). Product routing uses `ProductDecl`
|
||||
(`internal/corecmd/contract`; provenance label remains `cli.product_decl`).
|
||||
- `internal/cli/schema_hints/` is fully retired. Do not reintroduce HintFiles,
|
||||
audit JSON, or `imported/` baselines; declare on ProductDecl / the owning
|
||||
leaf instead.
|
||||
- Native Runtime Schema identity annotations, when present, as consistency
|
||||
assertions against `EffectiveCommandRegistry`. They must agree exactly and
|
||||
must never materialize, infer, or override registry identity.
|
||||
- Flag-to-interface property mappings and required/default semantics.
|
||||
- Generated files under `internal/cli/schema_agent_metadata/` and
|
||||
`internal/cli/schema_catalog.json` after running generation.
|
||||
- Do not expect generate-written Catalog delivery. Run
|
||||
`make generate-schema` only to refresh param aliases and prove assembly
|
||||
determinism. Do not expect or commit `schema_agent_metadata/`.
|
||||
|
||||
Run the reverse-completeness tests whenever the Cobra tree changes. A command
|
||||
that works through `dws <path>` but cannot be found through the matching
|
||||
`dws schema` lookup is a contract failure unless it has a reviewed exact
|
||||
exclusion.
|
||||
|
||||
Metadata parameter overlays must reference an exact public runnable Cobra leaf
|
||||
and real flags. They may override Schema description, interface-property/type
|
||||
mapping, `required`, and `required_when`; they must not create commands or
|
||||
flags, define an interface, or advertise an unknown RPC. Every authored entry
|
||||
requires `reviewed: true` and a non-empty review reason.
|
||||
`RegisterSchemaHints` / `ToolSchemaHint` overlays are fully removed. Parameter
|
||||
and selection facts must be declared on the owning leaf (`contract.ParamDecl` /
|
||||
`Contract`) or via `ProductDecl`; do not reintroduce overlay registries.
|
||||
|
||||
For Agent-authored metadata or selection edits:
|
||||
For Agent-authored selection edits:
|
||||
|
||||
1. Confirm the exact command and flag names in the current Cobra tree.
|
||||
2. Edit only the owning block (`metadata/` or `selection/`); do not mix fields.
|
||||
3. Add the smallest possible entry; do not copy generated Catalog fields into
|
||||
the input.
|
||||
4. Describe user-visible semantics in `review_reason` and parameter
|
||||
descriptions.
|
||||
5. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
2. Declare selection prose on the owning leaf (`Contract.Selection` /
|
||||
`DeclareLeafMetadata`) and product routing via `ProductDecl`; declare
|
||||
safety / parameters / interface on the same leaf.
|
||||
3. Do not copy generated Catalog fields into source inputs.
|
||||
4. Run generation, drift, Schema policy, and the focused CLI tests before
|
||||
proposing the change.
|
||||
|
||||
## Agent curation workflow (Schema hints)
|
||||
## Agent curation workflow
|
||||
|
||||
Use this workflow when refreshing Agent selection prose and confirmation
|
||||
alignment. Prefer **agent-authored review** over bulk merge scripts that dump
|
||||
`selection-review.json` or Skill Markdown into Catalog fields.
|
||||
Skill Markdown into Catalog fields.
|
||||
|
||||
Human-authored inputs are split into two blocks:
|
||||
Human-authored inputs:
|
||||
|
||||
| Block | Path | Owns |
|
||||
|---|---|---|
|
||||
| **metadata** | `internal/cli/schema_hints/metadata/<product>.json` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / `runtime_gate` / optional `parameters` |
|
||||
| **selection** | `internal/cli/schema_hints/selection/<product>.json` | `agent_summary` / `use_when` / `avoid_when` / `examples` (+ product routing) |
|
||||
| **declaration** | helpers / shortcut `Safety` + `Contract` / `contract.ParamDecl` + `ProductDecl` | `effect` / `risk` / `confirmation` / `idempotency` / `interface_*` / parameter facts / selection prose (`contract_final`) |
|
||||
|
||||
`index.json` only maps product IDs to those files. Do not mix selection fields
|
||||
into metadata files or metadata fields into selection files.
|
||||
`schema_hints/` is fully retired. Do not reintroduce HintFiles or audit JSON.
|
||||
|
||||
### Goals
|
||||
|
||||
1. **Selection prose** is decision-oriented (Feishu/Lark style): trigger intent,
|
||||
sibling-command routing, and outcome shape — not a restatement of the
|
||||
summary. Delivered Catalog provenance is `reviewed_explicit` from
|
||||
`selection/`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` iff the tool's
|
||||
metadata `runtime_gate != none` (for example `confirm_delete`, `typed_yes`,
|
||||
`confirm_dangerous`).
|
||||
3. **Parameter overrides** (former Manual `commands`) live on metadata tools as
|
||||
`parameters` (+ `cli_path`) and are applied into EffectiveCommandRegistry.
|
||||
summary. Delivered Catalog provenance is `contract_final` from leaf
|
||||
`Contract.Selection` / `ProductDecl`.
|
||||
2. **Safety** follows Runtime: `confirmation=user_required` when the leaf
|
||||
Contract/Safety (or remaining `runtime_gate` annotate) requires a user gate
|
||||
(for example `confirm_delete`, `typed_yes`, `confirm_dangerous`).
|
||||
3. **Parameter facts** are declared on the leaf (`contract.ParamDecl` /
|
||||
`Contract.Parameters` / FlagSpec). Do not reintroduce HintFile or
|
||||
`RegisterSchemaHints` overlays.
|
||||
|
||||
### Authoring
|
||||
|
||||
For every curated tool:
|
||||
|
||||
1. Edit `metadata/<product>.json` for safety/interface/gates/parameters.
|
||||
2. Edit `selection/<product>.json` for selection prose (`reviewed: true`,
|
||||
`review_reason`, `source_refs`).
|
||||
3. Run `make generate-schema`. Do not hand-edit generated
|
||||
`schema_agent_metadata/` or `schema_catalog.json`.
|
||||
1. Declare safety/interface/parameters/selection on the owning leaf
|
||||
(`DeclareLeafMetadata` / `Shortcut.Contract` / `contract.ParamDecl`) and product routing
|
||||
via `ProductDecl` when needed.
|
||||
2. Run `make generate-schema` (param aliases + assembly determinism). Do not
|
||||
create or commit `schema_catalog/` or Schema meta-index fixtures.
|
||||
|
||||
### Pull live MCP descriptions (personal token)
|
||||
|
||||
Pinned `internal/cli/schema_mcp_metadata.json` is a sanitized baseline. Prefer
|
||||
live Schema from a logged-in personal session:
|
||||
Schema delivery no longer embeds a pinned MCP JSON. Prefer live Schema from a
|
||||
logged-in personal session when reviewing interface facts before declaring them
|
||||
on the leaf:
|
||||
|
||||
```bash
|
||||
dws auth status # token_valid should be true
|
||||
dws cache refresh # refresh discovery / tools cache
|
||||
dws schema <mcp-canonical> -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" -f json
|
||||
dws schema <mcp-canonical> --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
# or CLI path: dws schema --cli-path "drive copy" --jq '{canonical_path,interface_ref,parameters}' -f json
|
||||
```
|
||||
|
||||
Resolve MCP identity via `interface_ref` when CLI canonical ≠ MCP path
|
||||
Resolve MCP identity via declared `interface_ref` when CLI canonical ≠ MCP path
|
||||
(example: CLI `drive.copy_document` → live `doc.copy_document`). On pull
|
||||
failure, fall back to Skill + Cobra Help + pinned MCP, and record evidence
|
||||
failure, fall back to Skill + Cobra Help, and record evidence
|
||||
(for example `live-dws-schema:<path>#FAILED`). Never print or commit tokens.
|
||||
`make fetch-mcp-metadata` writes an optional diagnostic dump under `artifacts/`
|
||||
only — do not commit it as a Schema pin.
|
||||
|
||||
Precedence when sources disagree: **Runtime/Cobra > live MCP > pinned MCP >
|
||||
Precedence when sources disagree: **Runtime/Cobra / leaf Contract > live MCP >
|
||||
Skill (evidence only)**.
|
||||
|
||||
### Parallel product agents
|
||||
|
||||
Split work by product groups. Each agent must:
|
||||
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live `dws schema`
|
||||
for its tools.
|
||||
- Hand-write selection + metadata; forbid wholesale JSON merges from review
|
||||
dumps.
|
||||
- Edit only its `metadata/<product>.json` and `selection/<product>.json`.
|
||||
- Read Skill, Cobra/`--help`, Runtime confirmation sites, and live
|
||||
`dws schema <leaf> --compact` for its tools. Mapping/interface/provenance
|
||||
audits may query the full leaf only through a narrow `--jq` / `--fields`
|
||||
projection; do not load an entire full leaf into Agent context.
|
||||
- Hand-write selection prose and leaf Contract / ProductDecl declarations;
|
||||
forbid wholesale JSON merges from review dumps.
|
||||
- Edit only its product’s leaf declarations (and `ProductDecl` when needed).
|
||||
- **Never** `git checkout` unrelated product files to “clean scope”.
|
||||
|
||||
### Regenerate and gates
|
||||
@@ -218,23 +337,24 @@ make generate-schema
|
||||
go test ./internal/app -run '^TestSheetFinalSchemaConfirmationMatchesRuntimeGuards$' -count=1
|
||||
```
|
||||
|
||||
`check-runtime-confirmation-truth.sh` compares live ContractFinal.Safety with the assembled ToolSpec `confirmation=user_required` and probes the runtime gate.
|
||||
`schema_hints/` must stay absent.
|
||||
|
||||
Example rules (fail generation otherwise):
|
||||
|
||||
- At most two examples per tool; no `--yes` in stored examples.
|
||||
- Examples must match live Cobra argv (path, flags, required groups).
|
||||
- No shell comments in examples.
|
||||
|
||||
After generation, spot-check Catalog: selection provenance is
|
||||
`reviewed_explicit` from `selection/`, and `user_required` count equals
|
||||
metadata `runtime_gate != none`.
|
||||
After generation, spot-check Catalog: selection and safety/interface
|
||||
provenance are `contract_final` from ProductDecl / leaf declarations
|
||||
(`user_required` must match Runtime confirmation gates).
|
||||
|
||||
`make generate-schema` is a full deterministic snapshot rebuild, not an
|
||||
incremental patch over the previous Catalog. It rereads every reviewed input,
|
||||
removes stale generated product metadata, and rewrites the exact metadata and
|
||||
Catalog projections. Incremental work happens only when an Agent or human
|
||||
edits selected `metadata/` or `selection/` entries; the next publication still
|
||||
recomputes all outputs. Generated files must never be read back as merge input,
|
||||
and byte guards fail generation if it changes the hint inputs or CommandRegistry.
|
||||
`make generate-schema` refreshes `param_aliases_generated.go` and runs
|
||||
assembly determinism (`check-schema-assembly.sh`). It does not rewrite a
|
||||
committed Catalog as delivery authority — runtime reassembles from
|
||||
declarations. Byte guards fail if generation mutates parameter-concept
|
||||
inputs; policy fails if the retired `schema_command_registry/` reappears.
|
||||
|
||||
Selection prose may choose a more or less restrictive recommendation. It cannot
|
||||
create a Cobra command or flag, change parameter facts, invent an
|
||||
@@ -280,7 +400,7 @@ proves natural-language understanding.
|
||||
|
||||
Semantic selection is an explicit opt-in live-model check. Run the smoke set
|
||||
(one positive and one negative scenario per product) with
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestManualAgentSelectionArkLive -count=1`.
|
||||
`DWS_AGENT_SELECTION_LIVE=1 ARK_API_KEY=... ARK_BASE_URL=... ARK_MODEL=... go test ./internal/app -run TestAgentSelectionArkLive -count=1`.
|
||||
Add `DWS_AGENT_SELECTION_FULL=1` to evaluate every committed tool scenario, or
|
||||
set `DWS_AGENT_SELECTION_CASES` to comma-separated fixture case IDs. Normal CI
|
||||
never calls a model; its blockers remain the reproducible fixture, binding,
|
||||
@@ -304,14 +424,21 @@ Preserve all candidates and the selected source in provenance, and fail
|
||||
same-precedence conflicts rather than silently merging them.
|
||||
|
||||
`required` is the exception. Cobra `MarkFlagRequired` is a hard floor: the
|
||||
final Agent projection must keep `required=true` and cannot be lowered by
|
||||
manual/hint overlays. Overlays may still raise an optional flag to required.
|
||||
`cli_required` continues to mirror the executable Cobra marker.
|
||||
final Agent projection must keep `required=true` and cannot be lowered by a
|
||||
lower-precedence source. A higher-precedence declaration may still raise an
|
||||
optional flag to required. `cli_required` continues to mirror the executable
|
||||
Cobra marker.
|
||||
|
||||
For command text, reviewed `ToolSchemaHint` wins first, then command-specific
|
||||
Cobra Help, then MCP metadata. Generic RPC prose may remain an unselected
|
||||
provenance candidate (and parameter-level `interface_description`); it must not
|
||||
overwrite a specialized leaf's title or description.
|
||||
For command-level description: **declare required, delivery Long may win**.
|
||||
`ContractDecl.Description` is mandatory at construction (declaration evidence).
|
||||
Catalog delivery prefers Cobra Long when present (provenance `cobra_help`,
|
||||
resolution `cobra_help_preferred`); without Long, the declared Description is
|
||||
delivered as `contract_final`. Title keeps declared ContractDecl /
|
||||
ContractFinal first, then Cobra Short, then MCP metadata. This is one authority
|
||||
chain with an explicit delivery preference — not two competing sources.
|
||||
Generic RPC prose may remain an unselected provenance candidate (and
|
||||
parameter-level `interface_description`); it must not overwrite a specialized
|
||||
leaf's title or description.
|
||||
|
||||
For every delivered `ToolSpec` and `ParameterSpec` field, the provenance
|
||||
winner value must exactly equal the delivered value. Checking only source,
|
||||
@@ -345,13 +472,15 @@ path; a generator unit test or JSON count alone is insufficient.
|
||||
`parameters` object for commands without flags. Keep it suitable for the #602
|
||||
compatibility baseline and fail rather than silently emitting a partial
|
||||
export.
|
||||
- `schema --all` is not normal command discovery. Use overview -> product/group
|
||||
-> leaf for routine Agent work. `--compact` is supported for context-saving
|
||||
projections, but a compact full export is not a complete compatibility
|
||||
baseline.
|
||||
- `schema --all` is not normal command discovery. Use overview -> compact
|
||||
product/group -> compact leaf for routine Agent work. `--compact` is the
|
||||
reviewed positive-field allowlist for Agent context: new full/audit fields
|
||||
must not appear there until explicitly reviewed. A compact full export is not
|
||||
a complete compatibility baseline.
|
||||
- `dws <path> --help` defines whether Cobra exposes a path and which flags the
|
||||
executable accepts. A leaf Schema defines Agent selection, parameter mapping
|
||||
and constraints, and safety/confirmation semantics. A conflict is contract
|
||||
drift, not permission to guess.
|
||||
executable accepts. A compact leaf defines Agent selection, CLI parameters,
|
||||
constraints, and safety/confirmation semantics. Full leaf fields such as
|
||||
`property`, `interface_ref`, and provenance are audit facts. A conflict is
|
||||
contract drift, not permission to guess.
|
||||
- Schema and Help describe commands; neither returns DingTalk business data.
|
||||
After discovery, execute the real read/search/list command to obtain data.
|
||||
|
||||
+495
@@ -6,6 +6,501 @@ The format is inspired by [Keep a Changelog](https://keepachangelog.com/) and th
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Streaming-card mentions** — `chat +messages-send-card` now accepts
|
||||
`--at-open-dingtalk-ids` and `--at-all` for group cards, passing mention
|
||||
targets to the initial card-creation request and prepending its returned
|
||||
`atTag` to the automatic streaming update.
|
||||
|
||||
## [1.0.58-beta.2] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
- **`dws sheet create-with-data`(新命令)** — 建表并写入初始数据与样式:`--values`(二维数组写默认表)/ `--sheets`(typed table 多工作表,二者必须给一个)/ `--styles`(`cell_styles` / `row_sizes` / `col_sizes` / `cell_merges`,顶层键对齐飞书 snake_case、列表项内字段兼容 camelCase)。所有结构、字段类型与枚举在创建文档之前校验,非法配置不会留下白建的空文档:`--sheets` 按 `table_put` 的输入契约逐字段校验(`columns` 必填且列名非空不重复、`data` 为二维且行宽与 `columns` 一致、单元格仅限字符串/数字/布尔/null、`dtypes`/`formats` 的键须是列名、`mode`/`header`/`allowOverwrite`/`startCell` 类型与取值、单表 30000 单元格上限),并拒绝未知键、snake_case 变体、`{"sheets":"bad"}` 这类畸形包装与 `sheetId`(服务端会静默丢弃写错的键,导致"只写了表头却报成功"的静默丢数据);`--values` 校验单元格为标量并受 30000 单元格 / 2000000 字符上限约束;`--styles` 的顶层键与列表项内字段同样拒绝未知键,避免样式只应用一半。写入后回读校验按 `startCell` / `header` / `mode` 推算的首个预期非空单元格,而非固定 A1。该命令是多步编排(建文档 → 探活 → 定位默认工作表 → 写数据 → 回读 → 可选样式),因此如实声明为独立叶子 `sheet.create_with_data` + `interface_mode: composite`(附评审 reason,按契约不带 `interface_ref`);**`dws sheet create` 保持原样不变**——仍是一次 `create_workspace_sheet` 直连(`interface_mode: mcp`),不新增 flag,避免让 Schema 消费者把编排步骤的参数误当成该 RPC 的入参。
|
||||
- **`dws sheet export-csv`(新命令)** — 同步导出单个工作表为 RFC4180 CSV,支持 `--sheet-id` 选表、`--range` 限定范围、`--value-render-option` 选取值模式;`--output` 落盘(为目录时按 `sheet-export.csv` 命名,落盘走 `AtomicWrite` 原子替换,写入失败时已有文件保持原样;父目录不存在按错误处理,不会自动创建),不传则把纯 CSV 打到 stdout(警告只走 stderr)。数据超出单次读取上限时默认报错、既不输出也不写文件,需 `--allow-truncated` 显式接受不完整结果。响应缺 `csv` 字段或类型不对一律报错,不会用 0 字节覆盖已有文件。该分支读的是 `get_range_as_csv`、与 xlsx 的异步导出任务毫无关系,因此独立成叶子 `sheet.export_csv` 并如实声明 `interface_mode: mcp` + `interface_ref: get_range_as_csv`;**`dws sheet export` 保持原样不变**——仍只导 xlsx(`interface_ref: submit_export_job`),flag 面仍是 `--node` / `--output`,csv 专属 flag 不会出现在它上面(此前挂在同一条命令上时,漏写 `--export-format csv` 会让 `--range` 被静默丢弃而导出整篇工作簿)。
|
||||
- **`sheet update-dimension --size-type`** — `pixel` / `standard`(恢复默认行高列宽)/ `auto`(按内容自适应行高,仅 ROWS)。
|
||||
- **`sheet replace --match-formula`** — 在公式文本中查找替换。
|
||||
- **`sheet range set-style` 扩展样式维度** — 新增 `--font-style`(斜体)/ `--font-line`(下划线、删除线)/ `--font-family` / `--border-styles-json`(四边边框;每条边只接受 `style` / `color`,未知键与非字符串 `color` 直接报错,不再静默忽略而画出无颜色的边框,`set-style` / `batch-set-style` / `create-with-data --styles` 三条路径同源校验)。
|
||||
- **`sheet range batch-set-style --ranges`** — 一组样式刷多个带工作表前缀的区域,组装为一次原子 `batch_update`。
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat message post-send ID handoff** (#897) — CLI Help and bundled Skills
|
||||
now document the `send` → `query-send-status` → `edit`/`recall` workflow,
|
||||
so callers can reuse returned task, message, and conversation IDs instead
|
||||
of searching message history by content.
|
||||
- **Sheet mono/multi Skill alignment** — replaces the oversized mono Sheet
|
||||
reference with the progressive routing layout, aligns all 20 Sheet topic
|
||||
references across the mono and multi bundles, and adds a content-policy guard
|
||||
that prevents the paired topic trees from drifting again.
|
||||
- **`sheet range set-style` 后端切换为 `set_cell_range`** — 样式统一走 cellStyles 路径(仅设样式、保留原值),这是斜体/下划线删除线/字体族/边框唯一可用的通道。`interface_ref` 由 `update_range` 变为 `set_cell_range`,12 个样式 flag 改为 reviewed mapping exclusion。CLI 用法向后兼容、无 flag 删除;schema-compatibility 经 reviewed 豁免判定为兼容(0 changed fields)。
|
||||
- **`sheet range batch-set-style` 改为单次原子提交** — 由本地循环多次 `update_range` 改为一次 `batch_update`,任一项失败默认整批回滚;`--continue-on-error` 由本地控制改为透传服务端。新增批量上限:最多 100 个区域且累计不超过 200000 个单元格。
|
||||
- **`sheet range batch-clear` / `batch-set-style` 的 `--ranges` 拒绝空白工作表前缀**(用户可见行为变更)— 此前只按原始串里 `!` 的位置判断,`" !A1:B2"` 修剪后工作表名成了空串,操作却照样带着 `sheetId: ""` 提交:服务端要么让整批 `batch_update` 失败,要么更糟——落到默认工作表而不是用户指定的那张表,且命令报成功。现在工作表名与范围都必须在修剪之后仍非空,否则在发起任何请求之前报错。`batch-set-style --batch` 的纯空白 `sheetId` / `range` 同样拒绝(此前只挡空字符串);`--batch` 下发仍用原值不替用户修剪,因为 `sheetId` 可以是允许带首尾空格的工作表**名**。两条 `--ranges` 路径现在共用同一个拆分器。
|
||||
- **`sheet insert-dimension` / `delete-dimension` / `update-dimension` 的 `--length` 严格校验**(用户可见行为变更)— 解析由 `fmt.Sscanf("%d")` 改为 `strconv.Atoi`。此前只消费前缀数字,`--length 2x` / `3foo` 会被静默当成 `2` / `3` 并对错误的行列数执行操作(删除方向不可回滚);现在整个值必须是合法正整数,否则报错「`--length` 必须为正整数(>= 1)」且不发起任何请求。**升级影响**:原先依赖这种宽松解析、在传畸形 `--length` 的脚本会开始报错,请把参数修正为纯数字。合法数字值行为不变,上限仍为 5000。`add-dimension` 的 `--length` 是 `Int` 类型 flag,一直由 cobra 严格校验,不受影响。
|
||||
- **CLI 接口兼容门禁支持 reviewed flag 类型豁免**(无用户可见变更)— `authoritative-interface-integrity` 与 `check-command-compatibility.sh` 此前一律拒绝历史命令的 flag 类型变更,即使新类型只是把同一套校验从 RunE 前移到解析期,也没有任何评审通道。现在两道门禁各带一张精确豁免表:命令路径 + flag 名 + 旧类型 → 新类型四元组全等才命中、方向敏感(`string`→`int` 与 `int`→`string` 是两个不同的键,只有被评审的方向可用),且仅当该 flag 的其他契约(shorthand / required / hidden / no-opt / scope)纹丝不动时才放行,因此豁免夹带不了别的破坏。首条也是目前唯一一条登记的是 `dws minutes permission apply --policy` 的 `string` → `int`(配合 #912):旧实现在 RunE 里做 `strconv.ParseInt(v, 10, 64)` 再校验 `[2,4]`,新实现由 pflag 以 `strconv.ParseInt(s, 0, 64)` 解析后仍校验 `[2,4]`,**历史上能成功的调用集是新调用集的子集**(base 0 额外接受 `0x3` 这类写法,只放宽不收紧),非法值依然失败、只是报错文案与时机前移;flag 默认值由 `""` 变 `"0"` 是类型的必然结果,两道门禁都不比较默认值,且该 flag 必须显式给出、默认值不可达。两张表必须逐字一致并有守卫测试锚定漂移——重复是被迫的而非选择:`check-authoritative-interface-baselines.sh` 会把整个 `scripts/policy/interface-baseline` 目录复制进检出历史版本的 worktree 再编译,那份拷贝不能 import 本分支新增的包。
|
||||
- **Schema 兼容门禁支持 reviewed 参数类型豁免**(无用户可见变更)— 接上一条。`schema-compatibility` 是同一个 `Interface Integrity` job 里排在两道 CLI 接口门禁之后的第三道检查,此前也一律拒绝已发布参数的 `type` 变更。由于前两道先失败、`set -e` 让它从未在 CI 上暴露,上一条豁免只解决了三分之二。现在 `checkParameterCompatibility` 也带一张精确豁免表:`<product>/<tool id>` + 参数名 + 旧类型 + 新类型四元组全等才命中、方向敏感,且仅当该参数**除 `type` 外的全部已发布字段逐字段相等**时才放行。这里刻意用相等性比较而非「没有产生其他兼容性错误」:放宽 `required` / `cli_required`、清空 `required_when`、扩宽 `enum`、清空 `interface_type`、经 reviewed mapping exclusion 清空 `property`——这些变化单独看都是兼容的、根本不产生错误,若以错误列表代替相等性检查,它们就能搭着一次已评审的类型迁移一起蒙混过关。结构体整体比较还意味着将来给 `parameterSchema` 新增字段时会自动纳入守卫,而不是悄悄放宽每一条既有条目。唯一条目是 `minutes/minutes.apply_minutes_permission` 的 `policy` 由 `"string"` 迁移到 `"integer"`(配合 #912):该 `type` 由 Cobra flag 类型投影而来(provenance `cobra_flag_type`),描述的是 CLI 如何接受取值;消费方据此拼装的是命令行,而 `--policy 4` 在两种声明下是同一个 argv,加引号的 `--policy "4"` 到 pflag 仍是 4,RunE 也仍校验 `[2,4]`——而且该参数映射的 property `policyId` 一直以数字上报,新声明比旧声明更贴近真实请求。表里的类型值必须是 `schemaType` 实际产出的带引号形态(`"string"` 而非裸 `string`),守卫测试用 `schemaType` 复算并校验类型名属于 JSON Schema 的封闭取值集合——`reviewedInterfaceRefRedirect` 曾因键的书写形态错误两次静默失效,这里不重犯。
|
||||
|
||||
## [1.0.58-beta.1] - 2026-08-07
|
||||
|
||||
### Added
|
||||
|
||||
- **Robot image and file messages** (#867) — `dws chat message send-by-bot`
|
||||
now supports image URLs and local-file uploads through explicit message
|
||||
types, while retaining Markdown as the default and preserving its existing
|
||||
title and text requirements.
|
||||
- **Conversation shortcut-bar management** (#877) — adds `dws chat toolbar`
|
||||
commands to list, add, hide, sort, and manage custom conversation shortcuts,
|
||||
with validation and confirmation for destructive removal.
|
||||
- **Complete AI Table Shortcut surface** (#901) — makes all 92 supported
|
||||
AI Table Shortcuts discoverable through Runtime Schema and adds reliable
|
||||
Base, table, record, attachment, view, dashboard, and workflow operations
|
||||
with explicit confirmation and result-verification semantics for writes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Doc import upload fallback** — `dws doc import` no longer fails on file
|
||||
formats outside the conversion whitelist (html, pdf, zip, extensionless,
|
||||
and any future format): it now hands the file to the document-space upload
|
||||
chain (the same primitive as `dws drive upload --workspace`), stores the
|
||||
original file at the requested `--folder`/`--workspace` target, and prints
|
||||
an explicit stderr notice with the supported-format list and the
|
||||
convert-to-md alternative. The fallback shares the import file checks
|
||||
(20MB cap, empty-file guard), keeps `--format json` / `--dry-run` output as
|
||||
a single JSON document, and marks the machine-readable result with
|
||||
`fallback: "upload"` and `converted: false` so agents never mistake the
|
||||
stored file for a converted online document. The fallback fails closed
|
||||
unless the commit response parses as JSON and carries a file identity
|
||||
(exposed as `dentry_id`); empty or unverifiable responses surface as
|
||||
errors instead of fabricated success. Importable formats and
|
||||
`dws sheet import` validation are unchanged.
|
||||
- **IM natural-target and history alignment** — Chat shortcuts can resolve natural user/group targets before execution, and message-history workflows expose bounded time ranges, ordering, explicit all-page controls, continuation ledgers, safe local export, and thread-reply pagination without treating empty or incomplete reads as successful results. Bundled mono/multi Skills and intent routing now describe the same executable surface.
|
||||
- **Sheet CSV formula writes** — `dws sheet csv-put` and batch `csv-put` now expose the service contract that CSV fields beginning with `=` are written as formulas. Prefix the field with an apostrophe to write literal text beginning with `=`; CSV content continues to pass through unchanged.
|
||||
- **Release-equivalent PR compatibility gate** (#889) — pull-request
|
||||
admission now runs command-surface compatibility checks against the current
|
||||
release baseline before code reaches `main`.
|
||||
- **Reviewer routing governance** (#903) — updates the Reviewer Router pool
|
||||
used for new ready PRs while retaining the existing current-head review and
|
||||
required-check gates.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Sheet formula verification** (#873) — `dws sheet formula-verify` now calls
|
||||
the registered remote tool name `verify_formula`; the previous
|
||||
`formula_verify` name failed at gateway dispatch.
|
||||
- **CLI and parameter recovery boundaries** (#864) — command and parameter
|
||||
recovery now fail closed when an Agent-provided path or flag cannot be
|
||||
reconciled with the executable CLI surface, reducing unsafe hallucinated
|
||||
retries.
|
||||
|
||||
## [1.0.57-beta.4] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded open CLI workflows** (#887) — adds calendar event-instance
|
||||
queries, Drive latest-file selection, Markdown diff, Mail calendar/export/
|
||||
share-to-chat workflows, and Minutes hot-word, permission, and audio-memo
|
||||
operations, with matching Schema and cross-platform coverage.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Multi-skill framework alignment** (#887) — folds long-tail skills into
|
||||
`dingtalk-misc`, renames the shared package to `dingtalk-shared`, removes
|
||||
stale Preview guidance, and reorganizes shared recipes and routing for more
|
||||
predictable Agent selection.
|
||||
- **Bounded Agent Schema delivery** (#887) — keeps compact and wire projections
|
||||
focused on executable contract facts, retires stale MCP metadata candidates,
|
||||
and teaches Agents to prefer `dws schema --compact` for bounded context.
|
||||
|
||||
### Deprecated
|
||||
|
||||
- **Recovery and discovery-cache compatibility surfaces** (#887) — keeps
|
||||
visible Deprecated `dws recovery` and `dws cache` compatibility stubs while
|
||||
retiring their former recovery engine and dynamic discovery-cache behavior.
|
||||
Recovery plan/execute/finalize now return an explicit “不再支持” notice, and
|
||||
Skills no longer teach either retired workflow.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Mail share-to-chat confirmation** (#887) — requires explicit confirmation
|
||||
before the first remote write, while preserving the confirmed sign-retry
|
||||
flow and covering both direct-success and retry responses.
|
||||
|
||||
## [1.0.57] - 2026-08-06
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.57-beta.4` baseline.
|
||||
It includes the v1.0.57 beta-line command-contract, document, chat, OA, Wiki,
|
||||
and compatibility improvements, plus the multi-skill framework alignment and
|
||||
expanded calendar, Drive, Markdown, Mail, and Minutes workflows validated in
|
||||
the final prerelease.
|
||||
|
||||
- **Promote v1.0.57-beta.4** — publishes the final validated prerelease
|
||||
baseline as stable `v1.0.57` without adding post-beta product changes.
|
||||
|
||||
## [1.0.57-beta.3] - 2026-08-06
|
||||
|
||||
### Added
|
||||
|
||||
- **Reviewed document shortcuts** (#880) — adds public document shortcuts for
|
||||
safe local downloads, content and history, review, media and style, and
|
||||
document access/sharing workflows, while retaining reviewed compatibility
|
||||
identities and confirmation safeguards for writes.
|
||||
- **Mentions in chat replies** (#881) — `chat message reply` now supports
|
||||
`--at-open-dingtalk-ids` and `--at-all`, forwarding reply mention fields and
|
||||
adding any required mention placeholders without changing existing send
|
||||
behavior.
|
||||
|
||||
## [1.0.57-beta.2] - 2026-08-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable Chat command compatibility** (#876) — restores the hidden migration
|
||||
entries for `chat send`, `chat history`, and their `im` aliases, preserving
|
||||
the v1.0.56 command surface while directing callers to the supported
|
||||
`chat message send/list` commands. Legacy flags now reach the same migration
|
||||
hints instead of failing during flag parsing.
|
||||
- **Drive download cancellation-test stability** (#876) — replaces a
|
||||
timing-sensitive worker-cancellation coverage test with a deterministic seam,
|
||||
reducing flaky CI without changing download behavior.
|
||||
|
||||
## [1.0.57-beta.1] - 2026-08-05
|
||||
|
||||
This beta starts the v1.0.57 line on top of v1.0.56. It packages the unified
|
||||
command-contract and runtime Schema architecture, complete Multi IM Chat
|
||||
coverage, document whiteboard and OA approval workflows, Wiki activity feeds,
|
||||
and compatibility and CI reliability fixes.
|
||||
|
||||
### Added
|
||||
|
||||
- **Contact personal-status updates** (#872) — adds `contact user update-ownness`
|
||||
(alias `set-ownness`) for updating a user's personal status text. The write
|
||||
operation maps reviewed `userId` and `ownnessText` parameters to the service
|
||||
contract and requires confirmation unless `--yes` is explicitly supplied.
|
||||
- **Document whiteboard workflows** (#861) — adds `doc whiteboard insert`,
|
||||
`whiteboard query/update`, and `doc media upload`. These commands support
|
||||
confirmed document-embedded whiteboard creation and updates, structured
|
||||
OpenNodes reads, and preparation of node-bound Vector/SVG resources.
|
||||
- **Complete Multi IM Chat coverage** (#860) — hardens deterministic group and
|
||||
stable-ID resolution, sending, querying, downloading, pagination, and JSON
|
||||
export. The remaining reviewed Chat Shortcuts enter Schema coverage, with
|
||||
destructive delete and clear operations aligned to confirmation gates.
|
||||
- **OA approval form workflows** (#853) — adds OA form-schema lookup,
|
||||
process forecast, and confirmed approval-instance creation, supporting both
|
||||
simple flags and complete `--request` payloads.
|
||||
- **Wiki activity-feed queries** (#862) — adds `wiki feed list` to retrieve
|
||||
workspace document activity, with cursor paging and optional file exclusion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Unified command and Schema contract framework** (#830) — Leaf commands and
|
||||
Shortcuts now use the shared typed `corecmd` base for flags, constraints,
|
||||
confirmation, Help, and runtime Schema projection. Schema delivery assembles
|
||||
from leaf Contract declarations at runtime; the retired hint overlays,
|
||||
pinned MCP metadata, and committed Catalog artifacts are no longer delivery
|
||||
authorities.
|
||||
- **Faster macOS CI without reducing native coverage** (#857) — narrows the
|
||||
macOS race suite to Keychain, codesign, and Darwin-only tests while adding a
|
||||
reachability contract that prevents native-only tests from being silently
|
||||
excluded.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Chat media-download JSON compatibility** (#854) — restores parseable
|
||||
`success`, `downloadUrl`, and `output` fields for
|
||||
`chat message download-media --format json` after a successful download,
|
||||
without progress output corrupting JSON stdout.
|
||||
|
||||
### Added
|
||||
|
||||
- **Document-embedded whiteboard workflows** — adds `doc whiteboard insert` for confirmed creation and part-ID verification, `whiteboard query/update` for structured OpenNodes reads and confirmed writes, and `doc media upload` for preparing node-bound Vector/SVG resources. The public adapter uses an explicit helper-only whiteboard endpoint, validates update envelopes locally, decodes `resultJson`, and publishes the full command, Schema, Skill, and safety contract migrated from `dws-wukong@e2da8ab947c6`.
|
||||
- **Robot image and file messages** — extends `chat message send-by-bot` with public image URL delivery through `--msg-type image --image-url` and local-file upload/send through `--msg-type file --file-path`, while preserving Markdown as the default message type.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat reply mentions** — `dws chat message reply` can @ specified group members with `--at-open-dingtalk-ids` or @ everyone with `--at-all`, forwarding the existing `send_personal_message` mention fields and automatically adding missing current-user `<@id>` / `<@all>` placeholders.
|
||||
- **Pinned MCP metadata retired** — deletes `internal/cli/schema_mcp_metadata.json` and removes its embed/loader/fallback role from Schema assembly. Catalog now assembles from Contract/ParamDecl/Interface + Cobra only; `make fetch-mcp-metadata` remains an optional diagnostic dump under `artifacts/` and refuses the retired pin path. Policy bans the pin from reappearing.
|
||||
- **MCP service review retired** — deletes `schema_mcp_service_review.json` and removes its policy jq / outputguard / test disposition gate (`notify` → `out_of_surface`, snapshot hash pin). No replacement ledger.
|
||||
- **Hints retired; ContractDecl is the leaf Schema source** (#830) — `schema_hints/`, Manual/Schema hint overlays, and `schema_agent_metadata/` delivery are removed. Selection, safety, parameters, and interface facts declare on ProductDecl / leaf `Contract` (`corecmd.ContractDecl` + `contract.ParamDecl` / `Safety`). Authoring renamed `SchemaDecl` → `ContractDecl`; nested fields reuse `contract.*` directly.
|
||||
- **Contract package seam** (#830) — types / ProductDecl live under `internal/corecmd/contract` (DTO only). Annotate writers live in `internal/corecmd/runtimeannotate`; Cobra-keyed ContractFinal store + Register live in `internal/corecmd/contractfinal`; homology gates in `internal/cli/homology`. All packages import `corecmd/*` directly; the former `cli/runtimeannotate` / `cli/contractfinal` shim packages are removed, and the `cli` root keeps only package-local aliases (`runtime_schema_seam.go`). Catalog/`ResolveMeta` stay on the `cli` delivery root. `internal/corecmd` must not import any `internal/cli` package.
|
||||
- **CommandMeta cache for ResolveMeta** — production `ResolveMeta` / leaf `--help` Safety project from the runtime-assembled `SchemaRegistry` into a `map[cli_path]CommandMeta` installed during `deliverySchemaCatalog` sync.Once. Steady-state lookups are O(1); full Catalog wire maps stay deferred. Registry `Source` stamps `runtime-assembled`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Fail-closed IM pagination and audit evidence** — `+chat-messages`, `+search-msg`, `+thread-replies`, `+at-me`, `+my-groups`, conversation lists, and favorites preserve partial-read failures, reject missing or stalled continuation state, deduplicate page boundaries, and publish completion evidence. The live-audit regression suite now rejects empty projections and incomplete reads instead of promoting them to passing results.
|
||||
- **Unified command safety and Shortcut runtime (H0)** — Shortcut leaves now execute through `corecmd.New`, sharing the same typed Safety confirmation gate as Leaf commands. EOF / closed stdin returns `confirmation_required`, and interactive `no` returns the existing non-zero cancellation validation error instead of reporting success for an operation that did not run. Pass `--yes` or `--dry-run` to skip the prompt.
|
||||
- **Constraint "provided" for `at_least_one` / `exactly_one` (H0)** — a flag set to an empty string (`--flag ""`) no longer counts as provided; previously bare Cobra `Changed` satisfied the constraint. Pass a non-blank value for a member of the group.
|
||||
- **Chat media download JSON compatibility** — `dws chat message download-media --format json` once again returns a clean `{success, downloadUrl, output}` result after the file is saved, preserving the temporary URL and resolved local path without progress text corrupting JSON stdout.
|
||||
|
||||
## [1.0.56] - 2026-08-04
|
||||
|
||||
This stable release promotes the fully delivered `v1.0.56-beta.4` baseline.
|
||||
It includes PR #852's resilient multipart Drive download implementation,
|
||||
together with the v1.0.56 beta-line command, Schema, Skill, and runtime
|
||||
improvements already validated through the prerelease channel.
|
||||
|
||||
### Added
|
||||
|
||||
- **Resilient multipart Drive downloads** (#852) — `drive download` and
|
||||
`drive download-version` support parallel chunk transfer, Range probing,
|
||||
fingerprint-validated checkpoint resume, automatic 401/403 credential
|
||||
refresh, and graceful interruption with checkpoint preservation.
|
||||
|
||||
## [1.0.56-beta.4] - 2026-08-04
|
||||
|
||||
This beta adds PR #852 on top of v1.0.56-beta.3. It makes Drive downloads
|
||||
resilient for large files through parallel transfer, validated resumable
|
||||
checkpoints, and automatic credential refresh.
|
||||
|
||||
### Added
|
||||
|
||||
- **Multipart Drive downloads** (#852) — adds `--part-size`, `--parallel`, and
|
||||
`--no-resume` to `drive download` and `drive download-version`. Files above
|
||||
the part-size threshold use a Range probe and parallel chunks, resume from a
|
||||
fingerprint-validated checkpoint, refresh credentials on 401/403, and keep
|
||||
the checkpoint when Ctrl+C interrupts a transfer.
|
||||
|
||||
## [1.0.56-beta.3] - 2026-08-03
|
||||
|
||||
This beta adds PRs #846 and #851 on top of v1.0.56-beta.2. It adds a
|
||||
service-provided Aitable workflow-editing reference command and makes local
|
||||
event-bus IPC reliable on shared filesystems by placing Unix sockets in a
|
||||
validated private runtime directory.
|
||||
|
||||
### Added
|
||||
|
||||
- **Aitable workflow editing reference** (#851) — adds `dws aitable workflow edit-example`, a parameter-free read command that returns the service-provided workflow editing documentation and `workflow-dsl/v1` examples through `aitable/edit_workflow_example`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Event bus sockets on shared filesystems** (#846) — Unix event buses now place their local IPC socket in a private per-user runtime directory (`XDG_RUNTIME_DIR` when available, otherwise a `0700` per-UID directory under the system temporary directory) while retaining locks, metadata, logs, and subscription state in the configured Workdir. Listener and dial paths validate directory ownership and permissions before use. This prevents `dws event consume` from failing with `bind: errno 524` when `~/.dws` is hosted on NFS, CSI, FUSE, or another filesystem that does not support Unix Domain Sockets without exposing the socket directly in a shared `/tmp` root. When `XDG_RUNTIME_DIR` is unavailable, the per-UID directory name is deterministic: ownership validation prevents endpoint hijacking, but another local user can pre-create the directory to deny service; multi-user deployments should provide a private `XDG_RUNTIME_DIR`.
|
||||
|
||||
## [1.0.56-beta.2] - 2026-07-30
|
||||
|
||||
This beta adds PRs #831 and #835 on top of v1.0.56-beta.1. It separates
|
||||
Agent Product observability and IM display identity from the stable
|
||||
edition-owned PAT and routing identity, and reduces common-path Skill context
|
||||
loading without changing the public command or Runtime Schema surface.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Agent Product identity separation** (#831) — sends `DWS_AGENT_PRODUCT` through the new `x-dws-agent-product` observability Header and uses a valid non-empty value for the IM `clawType` display label whenever `--ai-tag` is enabled. Because `--ai-tag` defaults to `true`, callers that set `DWS_AGENT_PRODUCT` change the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls preserve their existing wire shape by sending an empty IM `clawType`, while shortcut calls omit the argument. Unset or empty Product values omit the Header and preserve the active edition's IM display default.
|
||||
- **Agent Host dimension convention** (#831) — new integrations should send the runtime form (`cloud` or `desktop`) through `DWS_AGENT_HOST` and report the product separately through `DWS_AGENT_PRODUCT`. Legacy combined labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
- **Reduced common-path Skill context** (#835) — keeps the complete 97-command Chat Shortcut inventory in Runtime Catalog and leaf Schema while routing common intents through compact Skill tables and references. When an exact command path is already known, the mono Skill no longer requires eager loading of a complete product reference. The generated Skill policy now detects drift, forced full-reference loading, and context-budget regressions; the common Chat plus shared activation estimate drops from 7,301 to 4,771 `o200k_base` tokens without changing the 845-tool Schema surface.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Stable PAT/routing identity** (#831) — restores the CLI-emitted open-source HTTP `claw-type` and PAT `hostControl.clawType` to the edition-fixed `openClaw` value. `DWS_AGENT_PRODUCT` no longer changes those wire values, and the client continues to derive PAT, authentication, routing, and Discovery behaviour from the existing independent signals.
|
||||
- **Portable generated Skill validation** (#835) — resolves the mono Skill name by scanning upward from the generated target, keeping `--check` independent of the repository checkout path and preventing false drift failures when an ancestor directory resembles a Skill name.
|
||||
|
||||
## [1.0.56-beta.1] - 2026-07-30
|
||||
|
||||
This beta starts the v1.0.56 line on top of v1.0.55 and packages PRs #817,
|
||||
#806, and #834, together with release-validation fixes #838 and #839. It closes
|
||||
the remaining Agent-visible IM shortcut gaps, introduces reviewed
|
||||
command-scoped parameter normalization without guessing business identifiers
|
||||
or values, and prevents deterministic personal-event subscription failures
|
||||
from becoming unbounded retry storms.
|
||||
|
||||
### Added
|
||||
|
||||
- **Complete IM shortcut workflows** (#817) — publishes the previously excluded `+chat-messages`, `+messages-send`, `+messages-send-card`, `+search-msg`, and `+thread-replies` shortcuts in Runtime Schema. Unified send, streaming-card delivery, advanced search, thread replies, and opt-in resource downloads now share reviewed parameters, selection guidance, and runtime-aligned safety semantics.
|
||||
- **Reviewed parameter concept normalization** (#806) — adds a closed parameter-concept dictionary and generated command-level alias table, covering reviewed IM synonyms while preserving the boundaries between group, conversation, user, open-user, cursor, and paging identifiers.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Message delivery and resource handling** (#817) — resolves direct recipients through exact contact search, preserves rich and nested message resources, avoids same-name download overwrites, and prevents read shortcuts from silently returning empty results on non-interactive input.
|
||||
- **Parameter parsing safety** (#806) — rejects ambiguous, blocked, or conflicting aliases before dispatch, normalizes explicit boolean values such as `--dry-run false`, and keeps internal pre-parse handler details out of user-visible errors.
|
||||
- **Personal-event subscription retry safety** (#834) — adds cross-process attempt claims, deterministic backoff and jitter, `Retry-After` handling, terminal holds, compare-and-swap completion, and fail-closed state handling across all public personal-event subscriptions, preventing deterministic failures from causing unbounded callback retries.
|
||||
- **Scoped CI and release validation reliability** (#838, #839) — keeps scoped coverage aligned with intentionally skipped supporting profiles, gives focused race and Multi-profile E2E suites enough time for the current `internal/app` workload, and preserves hidden E2E diagnostics on failure.
|
||||
|
||||
## [1.0.55-beta.8] - 2026-07-30
|
||||
|
||||
This beta revalidates the `v1.0.55-beta.7` product baseline through a complete
|
||||
guarded release delivery. It carries no new product-facing command behavior;
|
||||
the new version is required because the published beta.7 artifacts succeeded
|
||||
on GitHub, npm, and Homebrew, but its enabled optional Gitee mirror failed and
|
||||
left that Release run ineligible for stable promotion.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Complete promotion evidence** — republishes the validated v1.0.55 command, Runtime Schema, Skill, authentication, and projection changes with the optional Gitee upload fallback disabled, so the release can produce one successful auditable delivery proof before stable promotion.
|
||||
|
||||
## [1.0.55] - 2026-07-30
|
||||
|
||||
This release promotes the validated `v1.0.55-beta.8` baseline to stable. It
|
||||
expands the public Workspace command surface and personal event consumption,
|
||||
makes the full built-in shortcut catalog available to Agents, and hardens
|
||||
multi-account routing, authentication compatibility, command safety, and
|
||||
response projection across the CLI.
|
||||
|
||||
### Added
|
||||
|
||||
- **Broader Workspace command surface** (#621, #676) — adds roughly 30 reviewed Drive, Doc, Sheet, and Chat leaf commands synchronized from Wukong, including Drive version and permission operations, document styling, Sheet comment/version/formula verification, and in-place text-emotion updates. A reusable declarative `LeafSpec` framework now delivers command identity, safety, selection, and guarded Help metadata consistently.
|
||||
- **Complete Agent-visible shortcut delivery** (#802, #815) — publishes all 210 built-in shortcuts as reviewed Runtime Schema leaves across 16 products, including 88 validated Chat shortcuts, with executable paths, parameters, constraints, selection guidance, dry-run capabilities, and runtime-aligned confirmation semantics.
|
||||
- **Expanded enterprise and event capabilities** (#790) — adds the HR Brain talent-pool, employee-profile, and structured-search command families; `dws mcp url get` resolves MCP Market endpoints; personal event consumption supports eight additional IM event keys, multi-key consumers, and targeted shutdown.
|
||||
- **Agent integration identity** (#804, #816) — adds validated `DWS_AGENT_HOST` and `DWS_AGENT_PRODUCT` labels for observability and product attribution while keeping them separate from authentication and authorization.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Progressive multi-Skill guidance and account safety** (#621, #821) — reorganizes bundled product guidance for progressive discovery and restores the mandatory rule that Agents must not guess an account when a multi-account organization has no unique current default.
|
||||
- **Supported Chat file delivery** — retires the legacy AppKey/AppSecret-backed `chat media upload` command from discovery and routes local files through `chat message send --msg-type file --file-path`, while callers with an existing media ID can continue sending images directly.
|
||||
- **Guarded release delivery** (#791) — strengthens immutable GitHub, npm, Homebrew, optional mirror, recovery, and version-allocation checks while keeping beta and stable publication role-gated and auditable.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut and message projection correctness** (#706, #783, #795) — prevents successful read shortcuts from silently projecting non-empty backend responses to empty results, renders rich, forwarded, and encrypted message forms safely, and fixes group-bot, bot-search, mail-thread, media-ID alias, and Todo paging response handling.
|
||||
- **Command contract edge cases** (#803) — makes approval revocation and document rollback honor dry-run before confirmation or preflight, fixes Drive and Doc rename semantics, restores Drive-specific metadata, and validates Todo reminder rules.
|
||||
- **Authentication and external-contact compatibility** (#756, #757) — migrates legacy global and organization-scoped credentials without cross-account token borrowing, preserves contacts that expose only `openDingTalkId`, and aligns message-resource flags with message-list output fields.
|
||||
|
||||
## [1.0.55-beta.7] - 2026-07-29
|
||||
|
||||
This beta supersedes the unpublished `v1.0.55-beta.6` candidate and packages
|
||||
PRs #621, #676, #757, #815, #816, and #821. It restores the mandatory
|
||||
multi-account safety rule caught by the sealed-release E2E gate while retaining
|
||||
the reviewed Wukong capability and multi-Skill synchronization, declarative
|
||||
command and Schema delivery, hardened Chat shortcuts, external contact
|
||||
resolution, and Agent product identity on top of the `v1.0.55-beta.5` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
|
||||
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
|
||||
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
|
||||
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
|
||||
- **Multi-account Skill safety contract** (#821) — restores the mandatory rule that an Agent must never choose the first, most recently logged-in, or most recently used account when an organization has multiple accounts without one unique `isOrgCurrent=true` default. A PR-level embedded-Skill regression test now catches removal before the full sealed-release E2E gate.
|
||||
|
||||
## [1.0.55-beta.6] - 2026-07-29
|
||||
|
||||
This beta packages PRs #621, #676, #757, #815, and #816, validating the Wukong
|
||||
capability and multi-Skill synchronization, declarative command and Schema
|
||||
delivery, hardened Chat shortcuts, external contact resolution, and Agent
|
||||
product identity on top of the `v1.0.55-beta.5` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Wukong capability and multi-Skill synchronization** (#621) — ports roughly 30 reviewed leaf commands into the open-source CLI across Drive, Doc, Sheet, and Chat, including in-place text-emotion updates, Drive version and permission operations, document styling, and Sheet comment/version/formula verification. The bundled multi-Skill framework is reorganized into progressive product references and routing guidance while retaining current open-source command, response, safety, and Runtime Schema contracts.
|
||||
- **Declarative leaf commands and unified metadata delivery** (#676) — adds the reusable `LeafSpec` command framework and migrates 27 DevApp commands without changing their paths or flags. Runtime consumers now resolve identity, safety, and selection through one embedded Catalog-backed API, and guarded Help output publishes the command's safety/confirmation annotation.
|
||||
- **Agent product identity** (#816) — adds the optional `DWS_AGENT_PRODUCT` override for the existing HTTP `claw-type` header while preserving each edition's default when unset. Product and runtime labels are caller-declared signals, not authentication credentials; services must validate supported values and must not grant access solely from them. The override does not change the separate IM message-display `clawType` parameter controlled by the edition and `--ai-tag`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Reviewed Chat shortcut delivery** (#815) — publishes 88 currently available Chat shortcuts after real-business validation, keeps three confirmed lower-service failures unavailable, strengthens semantic availability and dry-run contracts, and adds safe message-resource download plus group-member listing. Conversation filtering, IM routing/reporting, and member mute resolution are aligned with the validated backend identities.
|
||||
- **Agent identity label hardening** (#816) — limits `DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` to 64 ASCII bytes, trims only surrounding ASCII spaces and tabs, and rejects other control or Unicode whitespace. QwenWork integrations should report the two dimensions separately as `DWS_AGENT_PRODUCT=qwenwork` plus `DWS_AGENT_HOST=cloud` or `desktop`; previously used combined Host labels such as `qwenwork_cloud` remain syntactically valid for compatibility.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **External-contact and message-resource chaining** (#757) — the shared name-to-ID resolver keeps external or cross-organization contacts that expose only `openDingTalkId`, applies reviewed display-name fallbacks, and preserves organization-only filtering for commands that require `userId`. `chat +messages-resource-url` now accepts `--msg-id` and `--open-message-id` as aliases for `--message-id`, matching message-list response fields.
|
||||
|
||||
## [1.0.55-beta.5] - 2026-07-28
|
||||
|
||||
This beta validates expanded personal event consumption, complete Agent-visible
|
||||
Runtime Schema coverage for all 210 built-in shortcuts, Agent host
|
||||
observability, and hardened document, Drive, approval, and Todo command
|
||||
contracts on top of the `v1.0.55-beta.4` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **Expanded personal event consumption** (#790) — adds eight IM personal event keys, supports subscribing to and consuming multiple event keys in one `dws event consume` invocation, and adds targeted local-consumer shutdown when a subscription is stopped so other consumers can continue on the shared event bus.
|
||||
- **Shortcut Runtime Schema delivery** (#802) — publishes all 210 public built-in shortcuts as reviewed Agent-visible leaf tools across 16 product groups, with stable canonical identities, executable `+shortcut` CLI paths, parameter and cross-parameter constraints, selection guidance, interface metadata, and runtime-aligned safety/confirmation semantics. `dws shortcut list` remains the lightweight batch-discovery view, while leaf Schema now carries the complete Agent contract; declared string-slice defaults are also preserved consistently in Cobra and Schema.
|
||||
- **Agent host observability** (#804) — accepts an optional, validated `DWS_AGENT_HOST` label and sends it as `x-dws-agent-host` for logs and BI only; invalid values fail before CLI network activity, and the label never participates in authentication or routing.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Command contract edge cases** (#803) — approval revocation and document-version rollback now honor `--dry-run` before confirmation or remote preflight; `drive rename` removes only a suffix matching the node's current extension to avoid duplicate extensions while `doc rename` preserves the caller's exact display name; `doc info` keeps its stable MCP contract while `drive info` restores Drive-only metadata such as a non-null `fileSize`; and Todo reminder writes now reject invalid rule JSON while Help, Schema, and Skills distinguish a due time from an independently unreadable reminder rule.
|
||||
|
||||
## [1.0.55-beta.4] - 2026-07-27
|
||||
|
||||
This beta validates the shortcut projection fixes for group bots, bot search,
|
||||
and mail threads, together with hardened release delivery to Gitee and npm on
|
||||
top of the `v1.0.55-beta.3` baseline.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection fixes** (#795) — `chat +chat-bots` no longer projects a non-empty `list_group_bots` response to an empty list, `+bot-find` recognizes the `search_bots` response shape (`result.bots` entries with `botOpenDingTalkId`), and mail thread listings keep `lastUpdated` when the backend returns `lastModifiedDateTime`.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Hardened release delivery** — the Gitee mirror workflow can synchronize a specific release's assets on demand, release lookup tolerates Gitee's HTTP 200 null-body response for missing releases, npm dist-tag verification waits through slow registry CDN propagation with incremental backoff, and beta/stable release operations are role-enforced (#791).
|
||||
|
||||
## [1.0.55-beta.3] - 2026-07-24
|
||||
|
||||
This beta validates the HR Brain command surface, smoother guarded release
|
||||
automation, and deterministic Markdown test coverage on top of the
|
||||
`v1.0.55-beta.2` baseline.
|
||||
|
||||
### Added
|
||||
|
||||
- **HR Brain (`dws hrbrain`) command surface** — adds 11 commands across three groups: `talent-pool list/detail/employees` for talent pool browsing, `profile metadata/query/labels/career/performance` for employee profile data, and `search employees/employees-structured/fields` for basic and advanced (rule-based) people search. Ships with bundled mono/multi Skill guidance (`dingtalk-hrbrain`, `cli_version: ">=1.0.54"`); `search employees-structured` validates `--origin-json` as a JSON object and `--fields` as a JSON array before dispatch.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Smoother guarded releases** — publishes verified stable and beta Homebrew Formula updates directly from the release workflow, retries transient tag-ref visibility failures, lets an exact same-run retry reuse its sealed tag, and allows machine-verified rebuild recovery without a separate approval wait.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deterministic Markdown coverage** — replaces timing-dependent temporary-file deletion tests with synchronized file-stat failures so release admission no longer flakes on scheduler timing.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Faster guarded releases** — trusts an independently revalidated, exact `CHANGELOG.md`-only successor of an already admitted `main` commit, runs cloud planning alongside governance, and executes sealed-release automation, compatibility, and multi-profile validation in parallel with artifact compilation. Normal cloud publication no longer requires an unshareable local packaging preflight.
|
||||
- **Scoped document reads and group mentions** — `doc read --content-format jsonml` can return `outline`, `range`, `section`, or custom-tag fragments with depth and block-boundary controls; document comment create, reply, and update can mention groups through `--mentioned-open-conversation-id`.
|
||||
- **Drive overwrite uploads** — `drive upload --node <fileId>` can replace an existing Drive or document-space file, is mutually exclusive with `--folder`, supports dry-run, and requires confirmation before writing.
|
||||
- **Chat nickname clearing and cross-organization todos** — omitting `--nick` from `chat group update-nick` now clears the current user's group nickname, while `todo task list --query-all` queries todos across organizations.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Legacy authentication compatibility** (#756) — migrates pre-v1.0.53 global and organization-scoped login state into the identity-aware token store, including all legacy organizations, while keeping unresolved accounts isolated from exact `corpId:userId` credentials so external or no-directory identities can complete login without borrowing another user's token.
|
||||
|
||||
## [1.0.55-beta.1] - 2026-07-23
|
||||
|
||||
This beta validates MCP Market URL resolution, the supported Wukong local-file
|
||||
send path after retiring the legacy credential-based media upload command from
|
||||
discovery, and reliable message-read rendering for rich content, forwarded
|
||||
records, encrypted messages, and media-download ID aliases.
|
||||
|
||||
### Added
|
||||
|
||||
- **MCP URL resolution** — adds `dws mcp url get <mcpId>` for resolving a DingTalk MCP Market ID to the current user and organization scoped Streamable HTTP URL, while keeping the helper-only `mcp-meta` endpoint out of the public product command surface.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Chat local-file sending** — hides the open-source-only `chat media upload` compatibility command from Help, Schema, and bundled Skills, and removes its legacy AppKey/AppSecret OAPI path. Historical argv still receives an actionable migration error. Send local images and files through `chat message send --msg-type file --file-path`; callers that already hold a mediaId may continue to use `--msg-type image --media-id`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Shortcut projection silent-empty returns** (#783) — a batch of read shortcuts returned an empty list with exit 0 and no error envelope even when the underlying MCP tool returned data, so agents misread "no data". The projection resolvers now probe the real container keys (`processCodeList`, `values`, `wikiSpaces`, `itemList`, `groupList`, `recentItems`, `emailAccounts`, `deptUserList`, `labelUserList`, `roles`, `report_list`, and the grouped `get_org_labels` `labels[]`), unwrap items nested under a VO wrapper (`shiftVO` / `entityVO` / `userInfo`), and `todo +created-todos` uses the shared pager (`pageSize=20`) because the backend silently returns an empty page for `pageSize>20`. Affects contact/oa/wiki/drive/minutes/calendar/attendance/chat/report/smart shortcuts, each with a guard test asserting the real response shape projects non-empty. `scripts/shortcut_real_result.py` also gains an upper-vs-lower layer comparison so an exit-0 empty projection over a non-empty backend is scored as `projection-data-loss` in the real read-audit path rather than `real-ok`.
|
||||
- **Message-read shortcut projection** (#706) — the message-list shortcuts (`chat +chat-messages` / `+messages-list` / `+messages-list-direct` / `+at-me` / `+search-msg` / `+thread-replies`) now render card and out-of-office rich-content JSON as readable text (without ever rewriting ordinary text that merely embeds a JSON fragment), expand a forwarded chat record's nested `forwardMessages` instead of collapsing to a "[卡片]" summary, and mark undecryptable encrypted card messages as `[加密消息]`; the speaker is read from the bare `sender` key, nested `{name:…}` sender objects yield their display name, and the literal string `"null"` is treated as absent. Shared projection helpers now live in `internal/shortcut/chatmsg`. `chat message download-media` also gains `--msg-id` / `--open-message-id` aliases for its `--message-id` flag so agents copying the `openMessageId`/`msgId` output field no longer hit "unknown flag".
|
||||
|
||||
## [1.0.54] - 2026-07-21
|
||||
|
||||
This release promotes the validated `v1.0.54-beta.2` baseline to stable. It restores the default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Personal event output compatibility** (#743) — `event consume` once again preserves the transport envelope by default for `ndjson`/`json`/`pretty`, while retaining the existing `compact` processor. New Agent workflows opt into the event-specific top-level DTO with `--flatten`, which is mutually exclusive with `-f raw` and `--debug-raw-events`; `event schema --flatten` describes that DTO, while the default schema describes `type/event_type/data/headers` and points to `.data | fromjson`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Schema CLI path compatibility** (#738) — user-facing Schema lookups once again accept space-, dot-, and slash-separated CLI paths without weakening strict canonical identity resolution.
|
||||
- **Plugin CLI overlays** (#701) — installed plugins register their manifest-authored command trees again for HTTP and stdio servers, and a plugin may now replace a hidden compatibility fallback (for example `conference`) instead of being skipped as a distribution conflict.
|
||||
|
||||
## [1.0.54-beta.2] - 2026-07-21
|
||||
|
||||
This beta revalidates the same `v1.0.54-beta.1` source through the cloud release path with a sealed `OSS-Mirror: deferred` policy, because the manually tagged `v1.0.54-beta.1` push run failed on the unavailable OSS mirror channel after GitHub and npm delivery.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Release delivery only** — no source changes since `v1.0.54-beta.1`; see that section for the user-visible changes under validation (#743, #738, #701).
|
||||
|
||||
## [1.0.54-beta.1] - 2026-07-21
|
||||
|
||||
This beta validates the restored default transport envelope for personal event output with opt-in flattening, plus Schema CLI path and plugin overlay compatibility fixes, on top of the validated `v1.0.53-beta.7` baseline.
|
||||
|
||||
+54
-8
@@ -27,7 +27,9 @@ notes that are intentionally kept out of the repository root.
|
||||
|
||||
## Local Checks
|
||||
|
||||
Run the verification commands that match the surface you changed before you hand work back.
|
||||
Run the verification commands that match the surface you changed before you
|
||||
hand work back. The goal is useful, change-specific evidence, not a second
|
||||
local execution of every CI job.
|
||||
|
||||
Common repository checks already used here include:
|
||||
|
||||
@@ -44,19 +46,63 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Select the PR risk tier before choosing checks:
|
||||
|
||||
| Tier | Typical scope | Developer evidence | CI expansion |
|
||||
|---|---|---|---|
|
||||
| Documentation-only | Prose and documentation assets with no executable, generated, workflow, packaging, or interface change | Links/content/rendering plus repository asset checks | Lightweight documentation validation; all nine named contexts still report |
|
||||
| Standard | Ordinary implementation work with a stable package graph | Focused unit/integration tests and observable behavior for the changed path | Race tests for changed packages and their reverse dependencies, scope-matched HEAD/base coverage, and representative Darwin/Windows compilation |
|
||||
| High-risk | Workflow/policy, package graph, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure change | Relevant full or domain suite plus focused behavior evidence | Complete race suite, native platform tests, and all affected domain gates; protected `main` uses this tier |
|
||||
|
||||
Classification fails closed: an incomplete diff, package add/remove/rename, or
|
||||
uncertain dependency graph selects the high-risk suite. Native changed-code
|
||||
coverage is additionally selected for platform-sensitive code.
|
||||
|
||||
## Pull Request Checklist
|
||||
|
||||
1. Keep implementation and tests in sync.
|
||||
2. Run `./scripts/dev/ci-local.sh`.
|
||||
3. Run `./scripts/policy/check-command-surface.sh --strict` when command paths/flags change. CI also runs `./scripts/policy/check-command-compatibility.sh --base-ref <main-ref> --stable-ref <latest-GA-tag>` against both the target branch and latest stable release.
|
||||
4. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may change.
|
||||
5. Run `./scripts/release/verify-package-managers.sh` when packaging or installer surfaces change (run `make package` first).
|
||||
6. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
7. Include verification evidence in your PR description.
|
||||
2. Select the documentation-only, standard, or high-risk tier and run the
|
||||
smallest checks that prove the change. Use `./scripts/dev/ci-local.sh` when
|
||||
a complete local pass is warranted; it is not required for every ordinary
|
||||
PR.
|
||||
3. Include both the commands/results and user-visible or contract-level
|
||||
behavior evidence in the PR description.
|
||||
4. Run `./scripts/policy/check-command-surface.sh --strict` when command
|
||||
paths/flags change. CI resolves the exact merge-base, latest reachable
|
||||
non-withdrawn stable GA tag, and committed candidate SHA, then enters the single compatibility
|
||||
decision seam through
|
||||
`make authoritative-interface-integrity BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`.
|
||||
The Make target delegates to the authoritative wrapper; CI does not invoke a
|
||||
second comparator or the legacy fixture checker. See
|
||||
[CLI flag compatibility migration governance](docs/cli-interface-flag-migrations.md)
|
||||
for the reviewed two-stage `pending` → `consumed` lifecycle.
|
||||
Agent-visible flag migrations must also run
|
||||
`make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<latest-GA-tag> CANDIDATE_REF=<candidate-sha>`;
|
||||
it consumes the same base-owned ledger rather than a second exception list.
|
||||
5. Run `./scripts/policy/check-generated-drift.sh` when generated artifacts may
|
||||
change.
|
||||
6. Run `./scripts/release/verify-package-managers.sh` when packaging or
|
||||
installer surfaces change (run `make package` first).
|
||||
7. Update docs and `CHANGELOG.md` for behavior/interface changes.
|
||||
|
||||
## Submission Flow
|
||||
|
||||
1. Make the smallest atomic change that satisfies the task.
|
||||
2. Keep doc edits factual and limited to implemented behavior.
|
||||
3. Run the relevant verification commands.
|
||||
4. Report the validation results with the handoff.
|
||||
4. Report the validation results and risk tier with the handoff.
|
||||
5. Open a ready PR against `main`. Base-owned automation assigns one eligible
|
||||
peer reviewer, balancing the current open-review load and excluding the
|
||||
author. A new head push re-enters the same routing flow when the latest
|
||||
revision still needs review.
|
||||
6. After the latest push has one peer approval and the exact nine required
|
||||
contexts are current and green, auto-merge completes the PR. If `main`
|
||||
advances first, strict status checks revalidate the branch; no separate
|
||||
routine merge request is needed.
|
||||
|
||||
Contributors without repository write access stop at the PR flow. Explicitly
|
||||
authorized collaborators with `write`, `maintain`, or `admin` access can use
|
||||
[Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)
|
||||
to publish beta releases without manual approval. The same internal roles may
|
||||
start a stable release, but a different repository administrator must approve
|
||||
the `release-stable` Environment deployment before publication continues.
|
||||
|
||||
@@ -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.53-beta.4"
|
||||
version "1.0.58-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.53-beta.4/dws-darwin-arm64.tar.gz"
|
||||
sha256 "32a442d5b42dfed8512a695a7cef513722db6912f3c3168954cfaefb68e0b075"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-arm64.tar.gz"
|
||||
sha256 "1b2b6953f7f1ae1ca6ecb0702424ac0e1a976a6a5ff91e8ffc3b5ae495d98c7c"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-darwin-amd64.tar.gz"
|
||||
sha256 "00c694677b9ce2e1a711535740681defe0a5f83d6b72140f305483501628faf8"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-darwin-amd64.tar.gz"
|
||||
sha256 "a1c1b3c58b48e04c0ae520062f9d6ab0dc961eddb635497bdb9b4345316e45f6"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-arm64.tar.gz"
|
||||
sha256 "98516620e861e516cf846cf418f1a0ad5c5eb9a8681bd066b1fd29f4847aca6a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-arm64.tar.gz"
|
||||
sha256 "7f35e3c4734f17b125a8c32f3c95e05d1410f683cf6956be857ee9349f8e4d36"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-linux-amd64.tar.gz"
|
||||
sha256 "266df80e8a989971789a157dd134685a7c9eda01dd1d082719ec9e09d5a07bf0"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-linux-amd64.tar.gz"
|
||||
sha256 "37beb9e39790563cf0584ac23376f713bf2eb2c50cff4222965e831ac9adbb0e"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.53-beta.4/dws-skills.zip"
|
||||
sha256 "6770511ab9b04b4d97da1858069ff694830ba91d47c1e8564fd85856f95b016e"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.58-beta.2/dws-skills.zip"
|
||||
sha256 "7e10fead4192059c98d596c5b1886f77fd550526de5cd18c425cdad6fd64cd3a"
|
||||
end
|
||||
|
||||
def install
|
||||
|
||||
@@ -1,32 +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.52"
|
||||
version "1.0.57"
|
||||
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.52/dws-darwin-arm64.tar.gz"
|
||||
sha256 "4f6b4d064a76bcefac42feb5f356253fe43f9499b8cec9d2cdf202e7d3b9b60c"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-arm64.tar.gz"
|
||||
sha256 "c01c28dc13948a70fca905207073dc8dbd22f7ba7fc90e68b3316eb9a9c98e88"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-darwin-amd64.tar.gz"
|
||||
sha256 "abc87128f4b98d0a01ea99235449031971db8fa4ce94167403e3b736c4b81e9a"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-darwin-amd64.tar.gz"
|
||||
sha256 "d7baa218beefc851c6a933b456055195f8272984ce008d7e0122bdfc5dad94ea"
|
||||
end
|
||||
end
|
||||
|
||||
on_linux do
|
||||
if Hardware::CPU.arm?
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-arm64.tar.gz"
|
||||
sha256 "0d357ef0535f99f2f63b5ecbfdee9c32448be2a2c24f3096c03126b3b7570bc5"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-arm64.tar.gz"
|
||||
sha256 "0bbe9c233a3ff585077bae1ac5000937c32d967846d14cc44c46f98d49b95ae2"
|
||||
else
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-linux-amd64.tar.gz"
|
||||
sha256 "b7dfd9a4b3489211359261747ed0cb9c8c261434bb762ad3f76df33bdbabd5cb"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-linux-amd64.tar.gz"
|
||||
sha256 "f113ce3654f21d1f9ecc7c196f815aeafbca54d377a347b244a15116c5cba698"
|
||||
end
|
||||
end
|
||||
|
||||
resource "skills" do
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.52/dws-skills.zip"
|
||||
sha256 "0fa3c8dec500c1659e6480d6772ae901b2d12d24322dd5d7283f016024290c21"
|
||||
url "https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/releases/download/v1.0.57/dws-skills.zip"
|
||||
sha256 "0c9667209cf30761427a8f9348149cbbf1e397aa3c25587e99f205bc7525e101"
|
||||
end
|
||||
|
||||
def install
|
||||
@@ -52,6 +53,7 @@ class DingtalkWorkspaceCli < Formula
|
||||
<<~EOS
|
||||
Agent Skills are bundled in #{pkgshare}/skills/dws.
|
||||
Run `dws skill setup` to install them into your Agent directories.
|
||||
|
||||
EOS
|
||||
end
|
||||
|
||||
|
||||
@@ -5,10 +5,12 @@ PUBLISH ?= 0
|
||||
YES ?= 0
|
||||
DWS_POLICY_TMPDIR ?= $(CURDIR)/.worktrees/policy-tmp
|
||||
POLICY_GOTMPDIR ?= $(DWS_POLICY_TMPDIR)/go
|
||||
SCHEMA_CATALOG_OUTPUT ?= artifacts/schema_catalog
|
||||
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 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 cli-smoke mock-mcp-smoke test-schema-agent-examples generate-schema generate-schema-agent-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 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
|
||||
|
||||
@@ -17,29 +19,31 @@ help:
|
||||
@printf " make build - Build the dws CLI binary\n"
|
||||
@printf " make test - Run the Go test suite\n"
|
||||
@printf " make test-plan - Verify every default Go package belongs to one CI test shard\n"
|
||||
@printf " make test-auth-legacy-compat - Run stable legacy authentication compatibility regressions\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"
|
||||
@printf " make policy - Check the built dws plus open-source and Schema policies\n"
|
||||
@printf " make interface-integrity - Check historical commands and help contracts still work\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> - Check the Git-owned PR merge-base\n"
|
||||
@printf " make interface-integrity [BASE_REF=<ref>] [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check authoritative CLI history\n"
|
||||
@printf " make authoritative-interface-integrity BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check Git-owned CLI history\n"
|
||||
@printf " make coverage-gate BASE_REF=<ref> - Enforce overall non-regression and 100%% changed-code coverage\n"
|
||||
@printf " make coverage-gate-platform BASE_REF=<ref> PROFILE=<file> - Enforce 100%% native changed-code coverage\n"
|
||||
@printf " make update-interface-baseline - Add new CLI contracts without removing history\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace all CLI compatibility history\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> - Check the complete Schema contract against the PR merge-base\n"
|
||||
@printf " make update-interface-baseline - Update the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make reset-interface-baseline - DANGEROUS: replace the non-authoritative CLI smoke fixture\n"
|
||||
@printf " make schema-compatibility BASE_REF=<ref> [STABLE_REF=<tag>] [CANDIDATE_REF=<ref>] - Check the authoritative Schema history\n"
|
||||
@printf " make skill-command-integrity - Check dws commands referenced by skills exist\n"
|
||||
@printf " make skill-context-budget - Check generated Skill drift and common-path context budgets\n"
|
||||
@printf " make multi-im-skill-chain-integrity - Check reviewed IM intents keep one default Skill route\n"
|
||||
@printf " make cli-smoke - Verify help for every public top-level command\n"
|
||||
@printf " make mock-mcp-smoke - Verify HTTP and stdio MCP request/response transport\n"
|
||||
@printf " make test-schema-agent-examples - Contract-check all Agent examples and dry-run the eligible subset\n"
|
||||
@printf " make generate-schema - Regenerate embedded Agent metadata and the release Catalog\n"
|
||||
@printf " make generate-schema-agent-metadata - Regenerate versioned Agent metadata\n"
|
||||
@printf " make generate-schema-catalog - Regenerate the embedded release Catalog\n"
|
||||
@printf " make generate-schema - Refresh param_aliases + verify Schema assembly determinism\n"
|
||||
@printf " make generate-schema-catalog - Optional assembled Catalog dump under artifacts/ (not a delivery step)\n"
|
||||
@printf " make package - Build all release artifacts locally\n"
|
||||
@printf " make changelog-pre VERSION=vX.Y.Z-beta.N - Prepare prerelease notes\n"
|
||||
@printf " make changelog-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Prepare stable notes\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish prerelease\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N [PUBLISH=1] - Validate or publish stable\n"
|
||||
@printf " make release-pre VERSION=vX.Y.Z-beta.N - Validate prerelease; publish official releases from Actions\n"
|
||||
@printf " make release-stable VERSION=vX.Y.Z FROM_BETA=vX.Y.Z-beta.N - Validate stable; publish official releases from Actions\n"
|
||||
@printf " make publish-homebrew-formula - Push dist/homebrew/dingtalk-workspace-cli.rb to a tap repo\n"
|
||||
|
||||
build:
|
||||
@@ -54,6 +58,10 @@ test:
|
||||
test-plan:
|
||||
@./scripts/ci/test-packages.sh verify
|
||||
|
||||
test-auth-legacy-compat:
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@GO="$(GO)" $(POLICY_ENV) ./scripts/policy/check-auth-legacy-compat.sh
|
||||
|
||||
lint:
|
||||
@./scripts/dev/lint.sh
|
||||
|
||||
@@ -76,12 +84,17 @@ fmt:
|
||||
$(GO_SOURCE_LIST) > "$$go_files"; \
|
||||
xargs -0 sh -c 'if [ "$$#" -gt 0 ]; then exec gofmt -w -- "$$@"; fi' sh < "$$go_files"
|
||||
|
||||
policy:
|
||||
policy: test-auth-legacy-compat
|
||||
@mkdir -p "$(POLICY_GOTMPDIR)"
|
||||
@$(POLICY_ENV) ./scripts/policy/check-open-source-assets.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-command-registry.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-skill-context-budget.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-multi-im-skill-chain.sh
|
||||
@python3 scripts/run_chat_shortcut_live_audit_test.py
|
||||
@$(POLICY_ENV) ./scripts/policy/check-command-surface.sh --strict
|
||||
@$(POLICY_ENV) ./scripts/policy/check-generated-drift.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-concepts.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-param-alias-cooccurrence.sh
|
||||
@$(POLICY_ENV) $(GO) test -count=1 ./internal/app -run '^(TestParamAlias(FixtureThroughEmbeddedDeliveryPath|ReadCommandFinalPayload|WriteCommandFinalPayload|CanonicalConflictFailsBeforeRunE|BlockedFlagReachesReviewedFinalError)|TestFlagConflictErrorFormattingIsDeterministic)$$'
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-catalog.sh
|
||||
@$(POLICY_ENV) ./scripts/policy/check-schema-binary.sh
|
||||
@$(POLICY_ENV) $(MAKE) test-schema-agent-examples
|
||||
@@ -90,10 +103,22 @@ edition-test:
|
||||
$(GO) test -v -count=1 ./pkg/editiontest/...
|
||||
|
||||
interface-integrity:
|
||||
@./scripts/policy/check-interface-baseline.sh
|
||||
@base_ref="$(BASE_REF)"; \
|
||||
candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$base_ref" ]; then base_ref="origin/main"; fi; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$$base_ref" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
authoritative-interface-integrity:
|
||||
@./scripts/policy/check-authoritative-interface-baselines.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-interface-baselines.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
coverage-gate:
|
||||
@./scripts/policy/check-coverage-gate.sh --base-ref "$(BASE_REF)" --scope-buildable
|
||||
@@ -108,11 +133,25 @@ reset-interface-baseline:
|
||||
@./scripts/policy/check-interface-baseline.sh --reset
|
||||
|
||||
schema-compatibility:
|
||||
@./scripts/policy/check-authoritative-schema-compatibility.sh --base-ref "$(BASE_REF)"
|
||||
@candidate_ref="$(CANDIDATE_REF)"; \
|
||||
if [ -z "$$candidate_ref" ]; then candidate_ref="HEAD"; fi; \
|
||||
./scripts/policy/check-authoritative-schema-compatibility.sh \
|
||||
--base-ref "$(BASE_REF)" \
|
||||
--stable-ref "$(STABLE_REF)" \
|
||||
--candidate-ref "$$candidate_ref"
|
||||
|
||||
skill-command-integrity:
|
||||
@./scripts/policy/check-skill-commands.sh
|
||||
|
||||
skill-context-budget:
|
||||
@./scripts/policy/check-skill-context-budget.sh
|
||||
|
||||
multi-im-skill-chain-integrity:
|
||||
@./scripts/policy/check-multi-im-skill-chain.sh
|
||||
|
||||
skill-mono-multi-content:
|
||||
@./scripts/policy/check-mono-multi-skill-content.sh
|
||||
|
||||
cli-smoke:
|
||||
@./scripts/policy/check-cli-smoke.sh
|
||||
|
||||
@@ -120,42 +159,68 @@ mock-mcp-smoke:
|
||||
$(GO) test -v -count=1 -run '^(TestHTTPClientEndToEnd|TestStdioClientEndToEnd)$$' ./internal/transport
|
||||
|
||||
test-schema-agent-examples:
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestManualAgentExamplesDryRun$$'
|
||||
DWS_AGENT_EXAMPLES_DRY_RUN=1 $(GO) test -v -count=1 ./internal/app -run '^TestAgentExamplesDryRun$$'
|
||||
|
||||
# generate-schema refreshes param_aliases_generated.go and verifies that
|
||||
# ResolveSchemaBuild assembly is deterministic. Catalog is runtime-assembled
|
||||
# (声明即 Catalog); cmd_schema_catalog is not a committed delivery step.
|
||||
# schema_agent_metadata/ and schema_hints/ must stay absent.
|
||||
generate-schema:
|
||||
@set -e; \
|
||||
registry_guard=$$(mktemp); \
|
||||
metadata_guard=$$(mktemp -d); \
|
||||
selection_guard=$$(mktemp -d); \
|
||||
trap 'rm -rf "$$registry_guard" "$$metadata_guard" "$$selection_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/schema_command_registry.json "$$registry_guard"; \
|
||||
cp -R internal/cli/schema_hints/metadata/. "$$metadata_guard/"; \
|
||||
cp -R internal/cli/schema_hints/selection/. "$$selection_guard/"; \
|
||||
concepts_guard=$$(mktemp); \
|
||||
concepts_schema_guard=$$(mktemp); \
|
||||
command_fallbacks_guard=$$(mktemp); \
|
||||
command_fallbacks_schema_guard=$$(mktemp); \
|
||||
trap 'rm -f "$$concepts_guard" "$$concepts_schema_guard" "$$command_fallbacks_guard" "$$command_fallbacks_schema_guard"' EXIT HUP INT TERM; \
|
||||
cp internal/cli/param_concepts.json "$$concepts_guard"; \
|
||||
cp internal/cli/param_concepts.schema.json "$$concepts_schema_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard"; \
|
||||
cp internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard"; \
|
||||
$(GO) generate ./internal/cli; \
|
||||
cmp -s internal/cli/schema_command_registry.json "$$registry_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_command_registry.json' >&2; \
|
||||
rm -rf internal/cli/schema_agent_metadata internal/cli/schema_agent_metadata_audit.json; \
|
||||
rm -f internal/cli/schema_meta_index.json; \
|
||||
if [ -e internal/cli/schema_command_registry ]; then \
|
||||
printf '%s\n' 'retired schema_command_registry/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
cmp -s internal/cli/param_concepts.json "$$concepts_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/metadata "$$metadata_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/metadata' >&2; \
|
||||
cmp -s internal/cli/param_concepts.schema.json "$$concepts_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/param_concepts.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
diff -qr internal/cli/schema_hints/selection "$$selection_guard" >/dev/null || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/schema_hints/selection' >&2; \
|
||||
cmp -s internal/cli/command_path_fallbacks.json "$$command_fallbacks_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.json' >&2; \
|
||||
exit 1; \
|
||||
}
|
||||
|
||||
generate-schema-agent-metadata:
|
||||
$(GO) run ./internal/generator/cmd_schema_agent_metadata \
|
||||
-root . \
|
||||
-registry internal/cli/schema_command_registry.json \
|
||||
-output-dir internal/cli/schema_agent_metadata \
|
||||
-audit-output internal/cli/schema_agent_metadata_audit.json
|
||||
}; \
|
||||
cmp -s internal/cli/command_path_fallbacks.schema.json "$$command_fallbacks_schema_guard" || { \
|
||||
printf '%s\n' 'generation modified reviewed input internal/cli/command_path_fallbacks.schema.json' >&2; \
|
||||
exit 1; \
|
||||
}; \
|
||||
if [ -e internal/cli/schema_hints ]; then \
|
||||
printf '%s\n' 'retired schema_hints/ must not reappear after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
if [ -e internal/cli/schema_meta_index.json ]; then \
|
||||
printf '%s\n' 'retired schema_meta_index.json must not remain after generation' >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
./scripts/policy/check-schema-assembly.sh
|
||||
|
||||
# Optional local/CI dump of an assembled Catalog under artifacts/ by default.
|
||||
# Override SCHEMA_CATALOG_OUTPUT and SCHEMA_META_INDEX_OUTPUT as needed. This
|
||||
# is not a go:generate or production delivery step.
|
||||
generate-schema-catalog:
|
||||
$(GO) run -a ./internal/generator/cmd_schema_catalog \
|
||||
-root . \
|
||||
-output internal/cli/schema_catalog.json
|
||||
-output "$(SCHEMA_CATALOG_OUTPUT)" \
|
||||
-meta-index "$(SCHEMA_META_INDEX_OUTPUT)"
|
||||
|
||||
fetch-mcp-metadata:
|
||||
@printf ' %sFetching diagnostic MCP dump (not a Schema pin)%s\n' "$(COLOR_RUN)" "$(COLOR_RESET)"
|
||||
@./scripts/dev/fetch_mcp_metadata.sh
|
||||
|
||||
package:
|
||||
@version="$(if $(VERSION),$(VERSION),v0.0.0-SNAPSHOT)"; VERSION="$${version#v}" ./scripts/dev/build-all.sh
|
||||
|
||||
@@ -71,9 +71,7 @@ The installer ships skills in one of two layouts. CLI commands (`dws aitable ...
|
||||
| Mode | What gets installed | Best for |
|
||||
|------|----------------------|----------|
|
||||
| **mono** (stable, default) | One `dws` skill covering all products | Cross-product workflows; single entry point |
|
||||
| **multi** 🧪 **EXPERIMENTAL** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
> 🧪 **`multi` is currently EXPERIMENTAL / preview.** All product-scoped skills pass the dispatch verifier, but interface, naming and cross-skill references may change in future releases. For production / shared environments, prefer `mono`. File issues if you hit problems.
|
||||
| **multi** | Per-product skills (`dingtalk-aitable`, `dingtalk-calendar`, `dingtalk-chat`, ...) | Single-product tasks; smaller context per call |
|
||||
|
||||
How to pick:
|
||||
|
||||
@@ -371,7 +369,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
Use Cobra help and Schema for different parts of the command contract:
|
||||
|
||||
- `dws <path> --help` is the source of truth for whether a command exists and which flags the binary accepts.
|
||||
- `dws schema "<path>"` is the Agent contract for command selection, parameter mappings and constraints, risk, and confirmation semantics.
|
||||
- `dws schema "<path>" --compact` is the normative Agent view for command selection, CLI parameters and constraints, risk, and confirmation; use a full leaf with a narrow `--jq` projection for mapping or provenance audits.
|
||||
- If Help and Schema disagree, treat it as contract drift: pass only flags accepted by Cobra and use the more conservative safety semantics.
|
||||
- Schema describes commands; it does not read or search DingTalk business data. Execute the real product command after discovery.
|
||||
|
||||
@@ -380,23 +378,23 @@ Use Cobra help and Schema for different parts of the command contract:
|
||||
dws aitable record query --help
|
||||
|
||||
# Discover within a product, then inspect the selected leaf contract
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# Execute the real business query
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should prefer product/group discovery followed by a leaf query to avoid loading the full Catalog into context.
|
||||
`dws schema --all` exports the complete contract for tooling, CI, audits, and compatibility baselines. Agents should query progressively with `--compact`; its positive field allowlist prevents new full/audit fields from silently expanding Agent context.
|
||||
|
||||
### Agent Skills
|
||||
|
||||
The repo ships a complete Agent Skill system under `skills/`, organized into two layouts:
|
||||
|
||||
- `skills/mono/` — single-skill layout (one `SKILL.md` + `references/products/`), recommended default.
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`. 🧪 **EXPERIMENTAL / preview — see banner in each multi `SKILL.md` for caveats.**
|
||||
- `skills/multi/` — per-product skills (`dingtalk-aitable/`, `dingtalk-calendar/`, `dingtalk-chat/`, ...), each with its own `SKILL.md`.
|
||||
|
||||
Shared reviewed inputs for Schema generation live separately under `internal/cli/schema_hints/`. They are not Agent Skills and are excluded from binaries and release skill bundles.
|
||||
Leaf safety/parameters/selection prose for Schema generation come from ProductDecl / ContractFinal declarations in Go. The former `internal/cli/schema_hints/` HintFile tree is fully retired and must not reappear.
|
||||
|
||||
After installing, AI tools like Claude Code / Cursor can operate DingTalk directly through natural language:
|
||||
|
||||
@@ -443,7 +441,6 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
| Intent guide | `skills/mono/references/intent-guide.md` | Disambiguation for confusing scenarios (e.g. report vs todo) |
|
||||
| Global reference | `skills/mono/references/global-reference.md` | Auth, output formats, global flags |
|
||||
| Error codes | `skills/mono/references/error-codes.md` | Error codes + debugging workflows |
|
||||
| Recovery guide | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` handling |
|
||||
| Ready-made scripts | `skills/mono/scripts/*.py` | 13 batch operation scripts (see below) |
|
||||
|
||||
<details>
|
||||
@@ -474,7 +471,7 @@ Env vars: `DWS_SKILL_MODE=mono|multi` (also honored by `install.sh` / `install.p
|
||||
<details>
|
||||
<summary><strong>Personal Event Subscription</strong> — real-time DingTalk messages for event-driven agents</summary>
|
||||
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog currently covers messages that mention the current user, one-to-one messages with a specified user, and messages in a specified group.
|
||||
`dws event consume` subscribes as the currently logged-in user over a managed Stream WebSocket and emits each event as one NDJSON line on stdout. The public catalog covers scoped and all one-to-one/group messages, specified senders, read/recall/reaction events, and group title/disband lifecycle events.
|
||||
|
||||
The default `ndjson`, `json`, and `pretty` output preserves the transport envelope (`type`, `event_type`, string `data`, and `headers`) for existing scripts; `compact` retains its existing processor. Add `--flatten` to emit the stable top-level business fields used by Agent workflows. `--format` controls JSON serialization; `--flatten` controls the data structure and cannot be combined with `-f raw` or `--debug-raw-events`.
|
||||
|
||||
@@ -503,6 +500,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
|
||||
# Listen for messages in a specified group
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for all one-to-one or all group messages
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
|
||||
# Listen for a specified group's title changes, member changes, or disband event
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# Listen for multiple events for the same user in one process
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# Inspect local consumers and cancel a subscription
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
@@ -514,12 +530,13 @@ For one-to-one and specified-sender events, use exactly one target identity: `--
|
||||
|---------|---------|
|
||||
| Managed lifecycle | `consume` creates or reuses the personal subscription; `stop` cancels it and cleans local state |
|
||||
| Shared connection | Consumers for the same user share one local bus and cloud connection |
|
||||
| Multi-event process | One consume process can listen for compatible events for the same target while retaining one subscription per event |
|
||||
| Subscription isolation | Normal consumers match both event type and `subscribe_id` |
|
||||
| Agent-friendly output | Stream events are written to stdout as NDJSON; status and diagnostics use stderr |
|
||||
| Observability | `status` shows remote subscriptions, the personal bus, and local consumers |
|
||||
| Cross-platform | Unix Socket on macOS/Linux, Windows Named Pipe on Windows |
|
||||
|
||||
See `skills/multi/dingtalk-event/SKILL.md` for the Agent workflow and supported event parameters.
|
||||
See `skills/multi/dingtalk-misc/references/event.md` for the Agent workflow and supported event parameters.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -604,7 +621,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# Built-in jq expressions
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# Return only specific fields
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -616,9 +633,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema Introspection</strong> — Agent command discovery and execution contracts</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # discover product commands
|
||||
dws schema "aitable record query" # view the selected leaf contract
|
||||
dws schema "aitable record query" --jq '.tool.required' # view required fields
|
||||
dws schema aitable --compact # discover product commands
|
||||
dws schema "aitable record query" --compact # view the selected Agent leaf contract
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # view required fields
|
||||
dws schema --all # full export for CI/audit/baselines
|
||||
```
|
||||
|
||||
|
||||
+33
-16
@@ -71,9 +71,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| 模式 | 安装内容 | 适合场景 |
|
||||
|------|----------|----------|
|
||||
| **mono**(稳定,默认) | 一个 `dws` skill,覆盖全部产品 | 跨产品组合操作;单一入口召唤 |
|
||||
| **multi** 🧪 **试验版 / Preview** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
> 🧪 **multi 模式当前为 EXPERIMENTAL(试验版 / Preview)**。全部独立 skill 均通过 dispatch verifier,但接口、命名、跨 skill 引用后续可能调整。生产 / 共享环境建议优先用 `mono`。问题请提 issue 反馈。
|
||||
| **multi** | 按产品拆分的独立 skill(`dingtalk-aitable` / `dingtalk-calendar` / `dingtalk-chat` ...) | 单产品任务;每次召唤上下文更小 |
|
||||
|
||||
怎么选:
|
||||
|
||||
@@ -365,7 +363,7 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
命令帮助和 Schema 分别负责命令契约的不同部分:
|
||||
|
||||
- `dws <path> --help` 是命令是否存在、当前二进制接受哪些 flags 的事实源。
|
||||
- `dws schema "<path>"` 是 Agent 选命令、参数映射与约束、风险和确认语义的契约。
|
||||
- `dws schema "<path>" --compact` 是 Agent 选命令、CLI 参数与约束、风险和确认语义的规范视图;映射或 provenance 审计使用 full leaf 配合 `--jq` 精确投影。
|
||||
- Help 与 Schema 冲突时视为契约漂移:执行只传 Cobra 接受的参数,安全语义取更保守值。
|
||||
- Schema 只描述命令,不读取或搜索钉钉业务数据;发现命令后仍需执行真实产品命令。
|
||||
|
||||
@@ -374,23 +372,23 @@ dws contact user get-self --jq '.result[0].orgEmployeeModel | {name: .orgUserNam
|
||||
dws aitable record query --help
|
||||
|
||||
# 先在产品内发现命令,再查看选中 leaf 的契约
|
||||
dws schema aitable
|
||||
dws schema "aitable record query"
|
||||
dws schema aitable --compact
|
||||
dws schema "aitable record query" --compact
|
||||
|
||||
# 执行真实业务查询
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --limit 10
|
||||
```
|
||||
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应优先按产品/分组发现后查询 leaf,避免把整个 Catalog 加载进上下文。
|
||||
`dws schema --all` 会完整导出命令契约,供工具、CI、审计和兼容性基线使用。Agent 应使用 `--compact` 渐进查询;该视图采用正向字段白名单,full 新增的审计字段不会自动进入 Agent 上下文。
|
||||
|
||||
### Agent Skills
|
||||
|
||||
仓库内置完整的 Agent Skill 体系(`skills/` 目录),分为两套布局:
|
||||
|
||||
- `skills/mono/` — 单 skill 布局(一个 `SKILL.md` + `references/products/`),默认推荐。
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。🧪 **试验版 / Preview — 各 multi `SKILL.md` 头部有详细注意事项。**
|
||||
- `skills/multi/` — 每个产品一个独立 skill(`dingtalk-aitable/` / `dingtalk-calendar/` / `dingtalk-chat/` ...),每个 skill 自带 `SKILL.md`。
|
||||
|
||||
Schema 生成共享的 reviewed 输入单独位于 `internal/cli/schema_hints/`。它们不是 Agent Skill,也不会进入二进制或发布 skill 包。
|
||||
Schema 生成的叶子 safety/参数/选型文案由 Go 中的 ProductDecl / ContractFinal 声明驱动。原 `internal/cli/schema_hints/` HintFile 目录已完全退役,不得重新引入。
|
||||
|
||||
安装之后,Claude Code / Cursor 等 AI 工具就能通过自然语言直接操作钉钉:
|
||||
|
||||
@@ -437,7 +435,6 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
| 意图指南 | `skills/mono/references/intent-guide.md` | 易混淆场景消歧(如 report vs todo) |
|
||||
| 全局参考 | `skills/mono/references/global-reference.md` | 认证、输出格式、全局 flag |
|
||||
| 错误码 | `skills/mono/references/error-codes.md` | 错误码 + 调试流程 |
|
||||
| Recovery 指南 | `skills/mono/references/recovery-guide.md` | `RECOVERY_EVENT_ID` 处理 |
|
||||
| 现成脚本 | `skills/mono/scripts/*.py` | 13 个批量操作脚本(见下方) |
|
||||
|
||||
<details>
|
||||
@@ -468,7 +465,7 @@ DWS_SKILL_SOURCE=/path/to/skills dws skill setup --mode multi
|
||||
<details>
|
||||
<summary><strong>个人事件订阅</strong> — 实时接收钉钉消息,驱动事件触发的 Agent</summary>
|
||||
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录包括:当前用户被 @ 的消息、与指定用户的单聊消息、指定群的消息。
|
||||
`dws event consume` 使用当前 OAuth 登录用户建立托管的 Stream WebSocket 长连接,并把每条事件以 NDJSON 一行输出到 stdout。当前公开目录覆盖指定范围和全量单聊/群消息、指定发送人、已读/撤回/表情回应,以及群标题变更和群解散事件。
|
||||
|
||||
默认 `ndjson`、`json`、`pretty` 输出保留兼容 transport envelope(`type`、`event_type`、字符串 `data`、`headers`),`compact` 继续沿用原 processor。Agent 或新脚本显式加 `--flatten` 后,输出稳定的顶层业务字段。`--format` 控制 JSON 序列化,`--flatten` 控制数据结构,且不能与 `-f raw` 或 `--debug-raw-events` 同时使用。
|
||||
|
||||
@@ -497,6 +494,25 @@ dws event consume user_im_message_receive_o2o --open-dingtalk-id <openDingtalkId
|
||||
# 监听指定群的消息
|
||||
dws event consume user_im_message_receive_group --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 监听所有单聊或所有群消息
|
||||
dws event consume user_im_message_receive_o2o_all --flatten -f ndjson
|
||||
dws event consume user_im_message_receive_group_all --flatten -f ndjson
|
||||
|
||||
# 监听指定群标题变更、成员进退群或群解散
|
||||
dws event consume user_im_group_updated --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_added --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_member_exited --group <openConversationId> --flatten -f ndjson
|
||||
dws event consume user_im_group_disbanded --group <openConversationId> --flatten -f ndjson
|
||||
|
||||
# 一个进程监听同一用户的多个事件
|
||||
dws event consume \
|
||||
user_im_message_receive_o2o \
|
||||
user_im_message_read_o2o \
|
||||
user_im_message_recall_o2o \
|
||||
--user <userId> \
|
||||
--flatten \
|
||||
-f ndjson
|
||||
|
||||
# 查看本地 consume,并取消指定订阅
|
||||
dws event status
|
||||
dws event stop <subscribe_id>
|
||||
@@ -508,12 +524,13 @@ dws event stop <subscribe_id>
|
||||
|------|------|
|
||||
| 自动编排 | `consume` 创建或复用个人订阅,`stop` 取消订阅并清理本地状态 |
|
||||
| 共享连接 | 同一用户的多个 consumer 共享本地 bus 和云端长连接 |
|
||||
| 多事件进程 | 同一目标的兼容事件可由一个 consume 进程监听,每个事件仍有独立订阅 |
|
||||
| 订阅隔离 | 正常 consumer 同时按事件类型和 `subscribe_id` 匹配 |
|
||||
| Agent 友好输出 | Stream 事件写入 stdout,连接状态和诊断信息写入 stderr |
|
||||
| 状态可观测 | `status` 同时显示服务端订阅、personal bus 和本地 consumers |
|
||||
| 跨平台 | macOS/Linux 使用 Unix Socket,Windows 使用 Named Pipe |
|
||||
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-event/SKILL.md`。
|
||||
Agent 工作流和事件参数详见 `skills/multi/dingtalk-misc/references/event.md`。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -598,7 +615,7 @@ dws aitable record query --base-id BASE_ID --tabel-id TABLE_ID # --tabel-i
|
||||
```bash
|
||||
# 内置 jq 表达式
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --jq '.invocation.params'
|
||||
dws schema "dev app create" --jq '.tool.required'
|
||||
dws schema "dev app create" --jq '.parameters'
|
||||
|
||||
# 只返回指定字段
|
||||
dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocation,response
|
||||
@@ -610,9 +627,9 @@ dws aitable record query --base-id BASE_ID --table-id TABLE_ID --fields invocati
|
||||
<summary><strong>Schema 自省</strong> — Agent 命令发现与执行契约</summary>
|
||||
|
||||
```bash
|
||||
dws schema aitable # 发现产品命令
|
||||
dws schema "aitable record query" # 查看选中 leaf 契约
|
||||
dws schema "aitable record query" --jq '.tool.required' # 查看必填字段
|
||||
dws schema aitable --compact # 发现产品命令
|
||||
dws schema "aitable record query" --compact # 查看 Agent leaf 契约
|
||||
dws schema "aitable record query" --jq '[.parameters | to_entries[] | select(.value.required)]' # 定向查看必填字段
|
||||
dws schema --all # CI/审计/基线的全量导出
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,413 @@
|
||||
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
||||
// and writes a local diagnostic dump. It is NOT a Schema delivery refresh:
|
||||
// schema_mcp_metadata.json is retired; production Catalog assembles from
|
||||
// Contract/ParamDecl/Interface + Cobra only.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// dws auth login # ensure valid auth
|
||||
// make fetch-mcp-metadata # writes artifacts/mcp_metadata_diagnostic.json
|
||||
//
|
||||
// The tool loads auth from the DWS keychain, iterates static server endpoints
|
||||
// (internal/syncdata.StaticServers), calls tools/list on each, merges results,
|
||||
// and writes the requested -output path (refuses the retired pin path).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"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/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// toolLister is the tools/list capability consumed by run; production code
|
||||
// uses transport.Client, tests inject fakes.
|
||||
type toolLister interface {
|
||||
ListTools(ctx context.Context, endpoint string) (transport.ToolsListResult, error)
|
||||
}
|
||||
|
||||
// Injection points so run() is fully testable without network/keychain/exit.
|
||||
var (
|
||||
osExit = os.Exit
|
||||
getenv = os.Getenv
|
||||
loadTokenData = auth.LoadTokenDataKeychain
|
||||
staticServers = syncdata.StaticServers
|
||||
registrySource = collectedIdentityInterfaceRefs
|
||||
collectIdentitySpecs = cli.CollectIdentitySpecs
|
||||
listToolsTimeout = 30 * time.Second
|
||||
gitHeadPath = ".git/HEAD"
|
||||
newToolLister = func(token string) toolLister {
|
||||
return transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
||||
}
|
||||
)
|
||||
|
||||
func main() {
|
||||
osExit(run(os.Args[1:], os.Stderr))
|
||||
}
|
||||
|
||||
func run(args []string, stderr io.Writer) int {
|
||||
flags := flag.NewFlagSet("fetch_mcp_metadata", flag.ContinueOnError)
|
||||
flags.SetOutput(stderr)
|
||||
output := flags.String("output", "artifacts/mcp_metadata_diagnostic.json", "diagnostic dump path (not a Schema pin)")
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return 2
|
||||
}
|
||||
if retiredPinnedMCPMetadataPath(*output) {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: refusing to write retired Schema pin internal/cli/schema_mcp_metadata.json")
|
||||
return 2
|
||||
}
|
||||
|
||||
token := resolveToken(stderr)
|
||||
if token == "" {
|
||||
fmt.Fprintln(stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
|
||||
return 1
|
||||
}
|
||||
|
||||
client := newToolLister(token)
|
||||
|
||||
// Iterate ALL static server endpoints (26 servers covering all products).
|
||||
servers := staticServers()
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
||||
|
||||
// Collect command identity to build tool_name → interface_ref mapping.
|
||||
registryMap := loadRegistryInterfaceRefs(stderr)
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
||||
|
||||
// Load a previous diagnostic dump (if any) to preserve hand-curated
|
||||
// cross-server interface_ref mappings that automated matching can't derive.
|
||||
prevData, prevErr := os.ReadFile(*output)
|
||||
prevTools := map[string]map[string]any{}
|
||||
if prevErr == nil {
|
||||
var prev struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if json.Unmarshal(prevData, &prev) == nil {
|
||||
prevTools = prev.Tools
|
||||
}
|
||||
}
|
||||
|
||||
// Start from previous data (preserves cross-server refs), then overwrite
|
||||
// with fresh MCP data where available.
|
||||
allTools := make(map[string]map[string]any)
|
||||
for k, v := range prevTools {
|
||||
allTools[k] = v
|
||||
}
|
||||
|
||||
// Reviewed cross-server interface_refs live only in the previous snapshot
|
||||
// (the registry stores canonical paths, not MCP identities). Build a
|
||||
// live-key → canonicals index so those tools get refreshed instead of
|
||||
// being skipped and frozen at the previous snapshot forever.
|
||||
crossRefs := buildCrossServerRefs(prevTools, registryMap)
|
||||
if len(crossRefs) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: cross-server ref index: %d live keys\n", len(crossRefs))
|
||||
}
|
||||
// Canonicals with a reviewed cross-server identity must only be fed by
|
||||
// that identity; a same-named tool on another server is a coincidence,
|
||||
// not a data source.
|
||||
crossOwned := map[string]bool{}
|
||||
for _, canonicals := range crossRefs {
|
||||
for _, canonical := range canonicals {
|
||||
crossOwned[canonical] = true
|
||||
}
|
||||
}
|
||||
totalRaw := 0
|
||||
failedServices := []string{}
|
||||
|
||||
for _, srv := range servers {
|
||||
endpoint := strings.TrimSpace(srv.Endpoint)
|
||||
if endpoint == "" {
|
||||
continue
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), listToolsTimeout)
|
||||
result, err := client.ListTools(ctx, endpoint)
|
||||
cancel()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, " [skip] %s: %v\n", srv.ID, err)
|
||||
failedServices = append(failedServices, srv.ID)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
|
||||
totalRaw += len(result.Tools)
|
||||
for _, tool := range result.Tools {
|
||||
name := strings.TrimSpace(tool.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
// Direct match: CLI canonical equals server-prefixed tool name
|
||||
// (e.g., "doc.copy_document"). Cross-owned canonicals are skipped
|
||||
// here — their reviewed identity feeds them below.
|
||||
canonicalKey := srv.ID + "." + name
|
||||
if ref, hasRef := registryMap[canonicalKey]; hasRef && !crossOwned[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonicalKey, tool, ref)
|
||||
}
|
||||
// Cross-server match: registry canonicals whose reviewed
|
||||
// interface_ref points at this live tool (one live tool may feed
|
||||
// several canonicals, e.g. advperm_enable/disable → set_advanced_permission).
|
||||
for _, canonical := range crossRefs[canonicalKey] {
|
||||
mergeLiveMCPTool(allTools, canonical, tool, registryMap[canonical])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
matched := 0
|
||||
for _, t := range allTools {
|
||||
if _, ok := t["interface_ref"]; ok {
|
||||
matched++
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
|
||||
|
||||
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
|
||||
// previous data, add stub entries (interface_ref only).
|
||||
stubs := 0
|
||||
for canonicalKey, ref := range registryMap {
|
||||
if _, exists := allTools[canonicalKey]; exists {
|
||||
continue
|
||||
}
|
||||
allTools[canonicalKey] = map[string]any{
|
||||
"interface_ref": ref,
|
||||
}
|
||||
stubs++
|
||||
}
|
||||
if stubs > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
|
||||
}
|
||||
|
||||
// Compute coverage fields required by check-schema-catalog.sh. Failed
|
||||
// services must be reported honestly so policy can spot snapshot gaps.
|
||||
if len(failedServices) > 0 {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %d/%d services unreachable: %s\n",
|
||||
len(failedServices), len(servers), strings.Join(failedServices, ", "))
|
||||
}
|
||||
|
||||
metadata := map[string]any{
|
||||
"version": 1,
|
||||
"source": "mcp-tools-list+cli-registry",
|
||||
"coverage": buildCoverage(len(servers), failedServices, totalRaw, len(allTools), stubs),
|
||||
"tools": allTools,
|
||||
}
|
||||
|
||||
// source_revision: git commit hash (proves provenance).
|
||||
if rev, err := os.ReadFile(gitHeadPath); err == nil {
|
||||
metadata["source_revision"] = strings.TrimSpace(string(rev))
|
||||
}
|
||||
|
||||
if err := writeMetadata(*output, metadata); err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
|
||||
return 0
|
||||
}
|
||||
|
||||
// resolveToken returns the access token from DWS_ACCESS_TOKEN or, as a
|
||||
// fallback, the DWS keychain.
|
||||
func resolveToken(stderr io.Writer) string {
|
||||
token := strings.TrimSpace(getenv("DWS_ACCESS_TOKEN"))
|
||||
if token != "" {
|
||||
return token
|
||||
}
|
||||
td, err := loadTokenData()
|
||||
if err != nil || td == nil || td.AccessToken == "" {
|
||||
return ""
|
||||
}
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(td.AccessToken))
|
||||
return td.AccessToken
|
||||
}
|
||||
|
||||
// writeMetadata marshals the snapshot and writes it to the output path.
|
||||
func writeMetadata(path string, metadata map[string]any) error {
|
||||
data, err := json.MarshalIndent(metadata, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal failed: %w", err)
|
||||
}
|
||||
data = append(data, '\n')
|
||||
if err := os.WriteFile(path, data, 0644); err != nil {
|
||||
return fmt.Errorf("write %s failed: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildCoverage reports snapshot coverage honestly: snapshot_services only
|
||||
// counts services whose tools/list succeeded, missing_services names the
|
||||
// failures, and matched_tools excludes registry stubs (entries carrying no
|
||||
// live MCP metadata) so a stub-heavy snapshot cannot claim full matching.
|
||||
func buildCoverage(sourceServices int, failedServices []string, sourceTools, surfaceTools, stubs int) map[string]any {
|
||||
missing := failedServices
|
||||
if missing == nil {
|
||||
missing = []string{}
|
||||
}
|
||||
return map[string]any{
|
||||
"surface_scope": "source_revision",
|
||||
"source_services": sourceServices,
|
||||
"snapshot_services": sourceServices - len(missing),
|
||||
"missing_services": missing,
|
||||
"source_tools": sourceTools,
|
||||
"surface_tools": surfaceTools,
|
||||
"matched_tools": surfaceTools - stubs,
|
||||
"aliased_tools": 0,
|
||||
"unmatched_tools": stubs,
|
||||
}
|
||||
}
|
||||
|
||||
// mergeLiveMCPTool replaces stale live-derived fields while retaining an
|
||||
// existing reviewed interface_ref. Some CLI canonicals intentionally route to
|
||||
// a differently named product/RPC, so the previous cross-server mapping must
|
||||
// survive even though title, description, and parameters are refreshed.
|
||||
func mergeLiveMCPTool(allTools map[string]map[string]any, canonicalKey string, tool transport.ToolDescriptor, fallbackRef map[string]string) {
|
||||
interfaceRef := any(fallbackRef)
|
||||
if previous := allTools[canonicalKey]; previous != nil {
|
||||
if reviewedRef, ok := previous["interface_ref"]; ok && reviewedRef != nil {
|
||||
interfaceRef = reviewedRef
|
||||
}
|
||||
}
|
||||
|
||||
entry := map[string]any{
|
||||
"title": tool.Title,
|
||||
"description": tool.Description,
|
||||
"interface_ref": interfaceRef,
|
||||
}
|
||||
if tool.InputSchema != nil {
|
||||
entry["parameters"] = extractParams(tool.InputSchema)
|
||||
}
|
||||
allTools[canonicalKey] = entry
|
||||
}
|
||||
|
||||
// buildCrossServerRefs indexes reviewed cross-server mappings from the
|
||||
// previous snapshot: for every registry canonical whose interface_ref names a
|
||||
// different MCP identity (product_id.rpc_name != canonical), the live key is
|
||||
// mapped back to that canonical. One live tool may serve several canonicals,
|
||||
// so values are slices, sorted for deterministic merge order.
|
||||
func buildCrossServerRefs(prevTools map[string]map[string]any, registryMap map[string]map[string]string) map[string][]string {
|
||||
index := map[string][]string{}
|
||||
for canonical, entry := range prevTools {
|
||||
if _, inRegistry := registryMap[canonical]; !inRegistry {
|
||||
continue
|
||||
}
|
||||
ref, ok := entry["interface_ref"].(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
productID, _ := ref["product_id"].(string)
|
||||
rpcName, _ := ref["rpc_name"].(string)
|
||||
if productID == "" || rpcName == "" {
|
||||
continue
|
||||
}
|
||||
liveKey := productID + "." + rpcName
|
||||
if liveKey == canonical {
|
||||
continue
|
||||
}
|
||||
index[liveKey] = append(index[liveKey], canonical)
|
||||
}
|
||||
for _, canonicals := range index {
|
||||
sort.Strings(canonicals)
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// collectedIdentityInterfaceRefs collects command identity from the live
|
||||
// command tree — the replacement for the retired reviewed CommandRegistry —
|
||||
// and derives the canonical_path → {product_id, rpc_name} mapping used for
|
||||
// interface_ref injection.
|
||||
func collectedIdentityInterfaceRefs() (map[string]map[string]string, error) {
|
||||
root := app.NewSchemaSourceRootCommand()
|
||||
specs, _, err := collectIdentitySpecs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect command identity: %w", err)
|
||||
}
|
||||
out := make(map[string]map[string]string, len(specs))
|
||||
for _, spec := range specs {
|
||||
cp := strings.TrimSpace(spec.CanonicalPath)
|
||||
if cp == "" || !strings.Contains(cp, ".") {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(cp, ".", 2)
|
||||
out[cp] = map[string]string{
|
||||
"product_id": parts[0],
|
||||
"rpc_name": parts[1],
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadRegistryInterfaceRefs builds the canonical_path → interface_ref mapping
|
||||
// from the collected command identity. 与旧实现同等告警:静默返回空映射会让
|
||||
// 所有 live tool 被丢弃、产出 stub-only 快照且零提示(P1#1 的故障模式)。
|
||||
func loadRegistryInterfaceRefs(stderr io.Writer) map[string]map[string]string {
|
||||
refs, err := registrySource()
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "fetch_mcp_metadata: warning: cannot collect command identity: %v\n", err)
|
||||
return map[string]map[string]string{}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
func retiredPinnedMCPMetadataPath(path string) bool {
|
||||
cleaned := strings.ReplaceAll(strings.TrimSpace(path), "\\", "/")
|
||||
return cleaned == "internal/cli/schema_mcp_metadata.json" ||
|
||||
strings.HasSuffix(cleaned, "/internal/cli/schema_mcp_metadata.json")
|
||||
}
|
||||
|
||||
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
||||
// the flat param-name → metadata map used by diagnostic dumps.
|
||||
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
||||
if inputSchema == nil {
|
||||
return nil
|
||||
}
|
||||
properties, ok := inputSchema["properties"].(map[string]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
requiredSet := map[string]bool{}
|
||||
if req, ok := inputSchema["required"].([]any); ok {
|
||||
for _, r := range req {
|
||||
if s, ok := r.(string); ok {
|
||||
requiredSet[s] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
params := make(map[string]map[string]any, len(properties))
|
||||
for name, raw := range properties {
|
||||
prop, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
meta := map[string]any{}
|
||||
if t, ok := prop["type"].(string); ok {
|
||||
meta["type"] = t
|
||||
}
|
||||
if d, ok := prop["description"].(string); ok {
|
||||
meta["description"] = d
|
||||
}
|
||||
if d, ok := prop["default"].(string); ok {
|
||||
meta["default"] = d
|
||||
}
|
||||
if e, ok := prop["enum"].([]any); ok {
|
||||
enums := make([]string, 0, len(e))
|
||||
for _, v := range e {
|
||||
if s, ok := v.(string); ok {
|
||||
enums = append(enums, s)
|
||||
}
|
||||
}
|
||||
if len(enums) > 0 {
|
||||
meta["enum"] = enums
|
||||
}
|
||||
}
|
||||
meta["required"] = requiredSet[name]
|
||||
params[name] = meta
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,612 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"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/syncdata"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageLoadRegistryInterfaceRefsCollectsIdentity(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
refs := loadRegistryInterfaceRefs(&stderr)
|
||||
if len(refs) == 0 {
|
||||
t.Fatal("loadRegistryInterfaceRefs() returned no collected commands")
|
||||
}
|
||||
|
||||
got, ok := refs["calendar.list_calendars"]
|
||||
if !ok {
|
||||
t.Fatal("calendar.list_calendars missing from collected command identity")
|
||||
}
|
||||
if got["product_id"] != "calendar" || got["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("calendar.list_calendars ref = %#v", got)
|
||||
}
|
||||
|
||||
direct, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() error = %v", err)
|
||||
}
|
||||
if len(direct) == 0 || direct["calendar.list_calendars"]["rpc_name"] != "list_calendars" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs() = %#v", direct["calendar.list_calendars"])
|
||||
}
|
||||
|
||||
prevRegistry := registrySource
|
||||
prevCollect := collectIdentitySpecs
|
||||
t.Cleanup(func() {
|
||||
registrySource = prevRegistry
|
||||
collectIdentitySpecs = prevCollect
|
||||
})
|
||||
registrySource = func() (map[string]map[string]string, error) {
|
||||
return nil, errors.New("collect boom")
|
||||
}
|
||||
stderr.Reset()
|
||||
if got := loadRegistryInterfaceRefs(&stderr); len(got) != 0 || !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("loadRegistryInterfaceRefs error path = %#v stderr=%q", got, stderr.String())
|
||||
}
|
||||
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return nil, cli.IdentityCollectionReport{}, errors.New("walk boom")
|
||||
}
|
||||
if _, err := collectedIdentityInterfaceRefs(); err == nil || !strings.Contains(err.Error(), "collect command identity") {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs wrap error = %v", err)
|
||||
}
|
||||
collectIdentitySpecs = func(*cobra.Command) ([]cli.CommandSpec, cli.IdentityCollectionReport, error) {
|
||||
return []cli.CommandSpec{
|
||||
{CanonicalPath: ""},
|
||||
{CanonicalPath: "nodot"},
|
||||
{CanonicalPath: "doc.create"},
|
||||
}, cli.IdentityCollectionReport{}, nil
|
||||
}
|
||||
gotRefs, err := collectedIdentityInterfaceRefs()
|
||||
if err != nil || len(gotRefs) != 1 || gotRefs["doc.create"]["rpc_name"] != "create" {
|
||||
t.Fatalf("collectedIdentityInterfaceRefs skip = %#v err=%v", gotRefs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
// Fan-out: two canonicals share one live tool; insertion order must
|
||||
// not affect the sorted result.
|
||||
"aitable.advperm_enable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
"aitable.advperm_disable": {
|
||||
"interface_ref": map[string]any{"product_id": "aitable-helper", "rpc_name": "set_advanced_permission"},
|
||||
},
|
||||
// Identity ref (live key == canonical) needs no cross entry.
|
||||
"doc.copy_document": {
|
||||
"interface_ref": map[string]any{"product_id": "doc", "rpc_name": "copy_document"},
|
||||
},
|
||||
// Not in the registry: must be ignored.
|
||||
"ghost.tool": {
|
||||
"interface_ref": map[string]any{"product_id": "ghost-helper", "rpc_name": "haunt"},
|
||||
},
|
||||
}
|
||||
got := buildCrossServerRefs(prevTools, registryMap)
|
||||
want := map[string][]string{
|
||||
"aitable-helper.set_advanced_permission": {"aitable.advperm_disable", "aitable.advperm_enable"},
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("index = %#v, want %#v", got, want)
|
||||
}
|
||||
for k, v := range want {
|
||||
if gv := got[k]; len(gv) != len(v) || gv[0] != v[0] || gv[1] != v[1] {
|
||||
t.Fatalf("index[%q] = %v, want %v", k, gv, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCrossServerRefsSkipsMalformedRefs(t *testing.T) {
|
||||
registryMap := map[string]map[string]string{
|
||||
"a.x": {"product_id": "a", "rpc_name": "x"},
|
||||
"a.y": {"product_id": "a", "rpc_name": "y"},
|
||||
"a.z": {"product_id": "a", "rpc_name": "z"},
|
||||
}
|
||||
prevTools := map[string]map[string]any{
|
||||
"a.x": {"interface_ref": "not-a-map"},
|
||||
"a.y": {"interface_ref": map[string]any{"product_id": "", "rpc_name": "r"}},
|
||||
"a.z": {"title": "no ref at all"},
|
||||
}
|
||||
if got := buildCrossServerRefs(prevTools, registryMap); len(got) != 0 {
|
||||
t.Fatalf("index = %#v, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRefreshesCrossServerTools(t *testing.T) {
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
"aitable.advperm_disable": {"product_id": "aitable", "rpc_name": "advperm_disable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{{ID: "aitable-helper", Endpoint: "https://helper.example"}}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "live title", Description: "live desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{
|
||||
"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}},
|
||||
"aitable.advperm_disable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}
|
||||
}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cross-server ref index: 1 live keys") {
|
||||
t.Fatalf("stderr = %q, want cross-server index log", stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, canonical := range []string{"aitable.advperm_enable", "aitable.advperm_disable"} {
|
||||
entry := snapshot.Tools[canonical]
|
||||
if entry["title"] != "live title" || entry["description"] != "live desc" {
|
||||
t.Fatalf("%s = %#v, want live refresh", canonical, entry)
|
||||
}
|
||||
ref := entry["interface_ref"].(map[string]any)
|
||||
if ref["product_id"] != "aitable-helper" || ref["rpc_name"] != "set_advanced_permission" {
|
||||
t.Fatalf("%s reviewed ref lost: %#v", canonical, ref)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunCrossOwnedCanonicalIgnoresNameCoincidence:canonical 拥有评审过的
|
||||
// 跨 server 身份时,另一 server 上恰好同名的工具不得直连覆盖其元数据——
|
||||
// 数据源只能是评审身份指向的 live 工具。
|
||||
func TestRunCrossOwnedCanonicalIgnoresNameCoincidence(t *testing.T) {
|
||||
registry := func() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"aitable.advperm_enable": {"product_id": "aitable", "rpc_name": "advperm_enable"},
|
||||
}, nil
|
||||
}
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "aitable", Endpoint: "https://aitable.example"},
|
||||
{ID: "aitable-helper", Endpoint: "https://helper.example"},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
// 同名巧合:aitable server 上恰好也有 advperm_enable。
|
||||
"https://aitable.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "advperm_enable", Title: "coincidence title", Description: "coincidence desc"},
|
||||
}},
|
||||
"https://helper.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "set_advanced_permission", Title: "owner title", Description: "owner desc"},
|
||||
}},
|
||||
},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, registry)
|
||||
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
prev := `{"tools":{"aitable.advperm_enable":{"title":"stale","interface_ref":{"product_id":"aitable-helper","rpc_name":"set_advanced_permission"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Tools map[string]map[string]any `json:"tools"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entry := snapshot.Tools["aitable.advperm_enable"]
|
||||
if entry["title"] != "owner title" || entry["description"] != "owner desc" {
|
||||
t.Fatalf("entry = %#v, want reviewed-identity source to win over name coincidence", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeLiveMCPToolRefreshesExistingMetadata(t *testing.T) {
|
||||
const canonical = "calendar.list_calendars"
|
||||
reviewedRef := map[string]any{
|
||||
"product_id": "calendar-helper",
|
||||
"rpc_name": "list_user_calendars",
|
||||
}
|
||||
allTools := map[string]map[string]any{
|
||||
canonical: {
|
||||
"title": "old title",
|
||||
"description": "old description",
|
||||
"interface_ref": reviewedRef,
|
||||
"parameters": map[string]any{
|
||||
"stale": map[string]any{"type": "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
live := transport.ToolDescriptor{
|
||||
Name: "list_calendars",
|
||||
Title: "new title",
|
||||
Description: "new description",
|
||||
InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"cursor": map[string]any{
|
||||
"type": "string",
|
||||
"description": "next page cursor",
|
||||
},
|
||||
},
|
||||
"required": []any{"cursor"},
|
||||
},
|
||||
}
|
||||
fallbackRef := map[string]string{
|
||||
"product_id": "calendar",
|
||||
"rpc_name": "list_calendars",
|
||||
}
|
||||
|
||||
mergeLiveMCPTool(allTools, canonical, live, fallbackRef)
|
||||
|
||||
got := allTools[canonical]
|
||||
if got["title"] != "new title" || got["description"] != "new description" {
|
||||
t.Fatalf("live metadata was not refreshed: %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(got["interface_ref"], reviewedRef) {
|
||||
t.Fatalf("interface_ref = %#v, want reviewed mapping %#v", got["interface_ref"], reviewedRef)
|
||||
}
|
||||
params, ok := got["parameters"].(map[string]map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("parameters type = %T, want refreshed parameter map", got["parameters"])
|
||||
}
|
||||
if _, stale := params["stale"]; stale {
|
||||
t.Fatalf("stale parameter survived refresh: %#v", params)
|
||||
}
|
||||
if cursor := params["cursor"]; cursor["type"] != "string" || cursor["description"] != "next page cursor" || cursor["required"] != true {
|
||||
t.Fatalf("cursor parameter = %#v", cursor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageReportsFailedServices(t *testing.T) {
|
||||
got := buildCoverage(26, []string{"doc", "sheet"}, 800, 813, 40)
|
||||
if got["source_services"] != 26 {
|
||||
t.Fatalf("source_services = %v, want 26", got["source_services"])
|
||||
}
|
||||
if got["snapshot_services"] != 24 {
|
||||
t.Fatalf("snapshot_services = %v, want 24 (26 sources - 2 failures)", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{"doc", "sheet"}) {
|
||||
t.Fatalf("missing_services = %#v, want failed service IDs", got["missing_services"])
|
||||
}
|
||||
// matched 必须剔除 stub 占位,unmatched 据实等于 stub 数。
|
||||
if got["matched_tools"] != 773 || got["unmatched_tools"] != 40 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 773/40 (813 surface - 40 stubs)", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
if got["source_tools"] != 800 || got["surface_tools"] != 813 {
|
||||
t.Fatalf("tool counts = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCoverageFullSnapshotHasNoMissingServices(t *testing.T) {
|
||||
got := buildCoverage(26, nil, 813, 813, 0)
|
||||
if got["snapshot_services"] != 26 {
|
||||
t.Fatalf("snapshot_services = %v, want 26", got["snapshot_services"])
|
||||
}
|
||||
if !reflect.DeepEqual(got["missing_services"], []string{}) {
|
||||
t.Fatalf("missing_services = %#v, want empty non-nil slice", got["missing_services"])
|
||||
}
|
||||
if got["matched_tools"] != 813 || got["unmatched_tools"] != 0 {
|
||||
t.Fatalf("matched/unmatched = %v/%v, want 813/0 for stub-free snapshot", got["matched_tools"], got["unmatched_tools"])
|
||||
}
|
||||
}
|
||||
|
||||
// fakeLister returns canned tools/list results per endpoint.
|
||||
type fakeLister struct {
|
||||
results map[string]transport.ToolsListResult
|
||||
errs map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeLister) ListTools(_ context.Context, endpoint string) (transport.ToolsListResult, error) {
|
||||
if err := f.errs[endpoint]; err != nil {
|
||||
return transport.ToolsListResult{}, err
|
||||
}
|
||||
return f.results[endpoint], nil
|
||||
}
|
||||
|
||||
// stubDeps swaps every injection point for the duration of one test.
|
||||
func stubDeps(t *testing.T, token string, keychain func() (*auth.TokenData, error), servers []syncdata.ServerInfo, lister toolLister, registry func() (map[string]map[string]string, error)) {
|
||||
t.Helper()
|
||||
origGetenv, origLoad, origServers, origNew, origRegistry := getenv, loadTokenData, staticServers, newToolLister, registrySource
|
||||
t.Cleanup(func() {
|
||||
getenv, loadTokenData, staticServers, newToolLister, registrySource = origGetenv, origLoad, origServers, origNew, origRegistry
|
||||
})
|
||||
getenv = func(key string) string {
|
||||
if key == "DWS_ACCESS_TOKEN" {
|
||||
return token
|
||||
}
|
||||
return ""
|
||||
}
|
||||
loadTokenData = keychain
|
||||
staticServers = func() []syncdata.ServerInfo { return servers }
|
||||
newToolLister = func(string) toolLister { return lister }
|
||||
registrySource = registry
|
||||
}
|
||||
|
||||
func testRegistryRefs() (map[string]map[string]string, error) {
|
||||
return map[string]map[string]string{
|
||||
"doc.copy_document": {"product_id": "doc", "rpc_name": "copy_document"},
|
||||
"doc.get_document": {"product_id": "doc", "rpc_name": "get_document"},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRefusesRetiredPinnedMCPMetadataPath(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
code := run([]string{"--output", "internal/cli/schema_mcp_metadata.json"}, &stderr)
|
||||
if code != 2 {
|
||||
t.Fatalf("run(retired pin) = %d, want 2", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "refusing to write retired Schema pin") {
|
||||
t.Fatalf("stderr = %q, want retired-pin refusal", stderr.String())
|
||||
}
|
||||
if !retiredPinnedMCPMetadataPath("internal/cli/schema_mcp_metadata.json") ||
|
||||
!retiredPinnedMCPMetadataPath("/tmp/repo/internal/cli/schema_mcp_metadata.json") ||
|
||||
retiredPinnedMCPMetadataPath("artifacts/mcp_metadata_diagnostic.json") {
|
||||
t.Fatal("retiredPinnedMCPMetadataPath classification is incorrect")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunNoTokenFails(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return nil, errors.New("no keychain") }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run(nil, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1", code)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "no auth token") {
|
||||
t.Fatalf("stderr = %q, want no-auth-token hint", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunInvalidFlagFails(t *testing.T) {
|
||||
stubDeps(t, "tok", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--nonexistent"}, &stderr); code != 2 {
|
||||
t.Fatalf("run() = %d, want 2", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenKeychainFallback(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) {
|
||||
return &auth.TokenData{AccessToken: "kc-token"}, nil
|
||||
}, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "kc-token" {
|
||||
t.Fatalf("resolveToken() = %q, want kc-token", got)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "loaded token from keychain") {
|
||||
t.Fatalf("stderr = %q, want keychain log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTokenEmptyKeychainToken(t *testing.T) {
|
||||
stubDeps(t, "", func() (*auth.TokenData, error) { return &auth.TokenData{}, nil }, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
if got := resolveToken(&stderr); got != "" {
|
||||
t.Fatalf("resolveToken() = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunWritesSnapshotWithHonestCoverage(t *testing.T) {
|
||||
servers := []syncdata.ServerInfo{
|
||||
{ID: "doc", Endpoint: "https://doc.example"},
|
||||
{ID: "sheet", Endpoint: "https://sheet.example"},
|
||||
{ID: "blank", Endpoint: " "},
|
||||
}
|
||||
lister := &fakeLister{
|
||||
results: map[string]transport.ToolsListResult{
|
||||
"https://doc.example": {Tools: []transport.ToolDescriptor{
|
||||
{Name: "copy_document", Title: "复制文档", Description: "copy", InputSchema: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{
|
||||
"doc_id": map[string]any{"type": "string", "description": "文档 ID", "default": "d", "enum": []any{"a", "b", 3}},
|
||||
"bogus": "not-a-map",
|
||||
},
|
||||
"required": []any{"doc_id", 42},
|
||||
}},
|
||||
{Name: " "},
|
||||
{Name: "not_in_registry"},
|
||||
}},
|
||||
},
|
||||
errs: map[string]error{"https://sheet.example": errors.New("boom")},
|
||||
}
|
||||
stubDeps(t, "env-token", nil, servers, lister, testRegistryRefs)
|
||||
|
||||
dir := t.TempDir()
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
prev := `{"tools":{"doc.get_document":{"interface_ref":{"product_id":"doc-helper","rpc_name":"fetch_document"}}}}`
|
||||
if err := os.WriteFile(output, []byte(prev), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
Version int `json:"version"`
|
||||
Coverage map[string]any `json:"coverage"`
|
||||
Tools map[string]map[string]any
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.Version != 1 {
|
||||
t.Fatalf("version = %d", snapshot.Version)
|
||||
}
|
||||
if got := snapshot.Coverage["snapshot_services"].(float64); got != 2 {
|
||||
t.Fatalf("snapshot_services = %v, want 2 (3 servers - 1 failed; blank endpoint not counted as failed)", got)
|
||||
}
|
||||
if got := snapshot.Coverage["missing_services"].([]any); len(got) != 1 || got[0] != "sheet" {
|
||||
t.Fatalf("missing_services = %v, want [sheet]", got)
|
||||
}
|
||||
live := snapshot.Tools["doc.copy_document"]
|
||||
if live == nil || live["title"] != "复制文档" {
|
||||
t.Fatalf("doc.copy_document = %#v, want live metadata", live)
|
||||
}
|
||||
params := live["parameters"].(map[string]any)
|
||||
docID := params["doc_id"].(map[string]any)
|
||||
if docID["type"] != "string" || docID["required"] != true || docID["default"] != "d" {
|
||||
t.Fatalf("doc_id = %#v", docID)
|
||||
}
|
||||
if enum := docID["enum"].([]any); len(enum) != 2 {
|
||||
t.Fatalf("enum = %v, want the 2 string members only", enum)
|
||||
}
|
||||
if _, ok := params["bogus"]; ok {
|
||||
t.Fatal("non-map property should be skipped")
|
||||
}
|
||||
prevRef := snapshot.Tools["doc.get_document"]["interface_ref"].(map[string]any)
|
||||
if prevRef["product_id"] != "doc-helper" {
|
||||
t.Fatalf("previous reviewed ref lost: %#v", prevRef)
|
||||
}
|
||||
if _, ok := snapshot.Tools["not_in_registry"]; ok {
|
||||
t.Fatal("tools outside the registry must be dropped")
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "services unreachable: sheet") {
|
||||
t.Fatalf("stderr = %q, want unreachable log", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunIgnoresCorruptPreviousSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
if err := os.WriteFile(output, []byte("{corrupt"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRegistryLoadFailureStillWritesStublessSnapshot(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, func() (map[string]map[string]string, error) { return nil, errors.New("no identity") })
|
||||
output := filepath.Join(t.TempDir(), "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "cannot collect command identity") {
|
||||
t.Fatalf("stderr = %q, want identity collection warning", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWriteFailure(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
var stderr bytes.Buffer
|
||||
badPath := filepath.Join(t.TempDir(), "missing-dir", "snapshot.json")
|
||||
if code := run([]string{"--output", badPath}, &stderr); code != 1 {
|
||||
t.Fatalf("run() = %d, want 1 on write failure", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteMetadataMarshalFailure(t *testing.T) {
|
||||
err := writeMetadata(filepath.Join(t.TempDir(), "out.json"), map[string]any{"bad": math.NaN()})
|
||||
if err == nil || !strings.Contains(err.Error(), "marshal failed") {
|
||||
t.Fatalf("err = %v, want marshal failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainDelegatesToRun(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
origExit, origArgs := osExit, os.Args
|
||||
t.Cleanup(func() { osExit, os.Args = origExit, origArgs })
|
||||
exitCode := -1
|
||||
osExit = func(code int) { exitCode = code }
|
||||
os.Args = []string{"fetch_mcp_metadata", "--output", filepath.Join(t.TempDir(), "snapshot.json")}
|
||||
main()
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("main() exited with %d, want 0", exitCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractParamsNilAndNonObjectSchemas(t *testing.T) {
|
||||
if got := extractParams(nil); got != nil {
|
||||
t.Fatalf("extractParams(nil) = %v, want nil", got)
|
||||
}
|
||||
if got := extractParams(map[string]any{"type": "object"}); got != nil {
|
||||
t.Fatalf("extractParams(no properties) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewToolListerBuildsAuthedClient(t *testing.T) {
|
||||
if lister := newToolLister("tok"); lister == nil {
|
||||
t.Fatal("newToolLister returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordsSourceRevision(t *testing.T) {
|
||||
stubDeps(t, "env-token", nil, nil, &fakeLister{}, testRegistryRefs)
|
||||
dir := t.TempDir()
|
||||
head := filepath.Join(dir, "HEAD")
|
||||
if err := os.WriteFile(head, []byte("ref: refs/heads/feature\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
origHead := gitHeadPath
|
||||
t.Cleanup(func() { gitHeadPath = origHead })
|
||||
gitHeadPath = head
|
||||
|
||||
output := filepath.Join(dir, "snapshot.json")
|
||||
var stderr bytes.Buffer
|
||||
if code := run([]string{"--output", output}, &stderr); code != 0 {
|
||||
t.Fatalf("run() = %d, stderr=%s", code, stderr.String())
|
||||
}
|
||||
data, err := os.ReadFile(output)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var snapshot struct {
|
||||
SourceRevision string `json:"source_revision"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &snapshot); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if snapshot.SourceRevision != "ref: refs/heads/feature" {
|
||||
t.Fatalf("source_revision = %q", snapshot.SourceRevision)
|
||||
}
|
||||
}
|
||||
@@ -17,18 +17,23 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/app"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/i18n"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var newRootCommand = func() *cobra.Command { return app.NewRootCommand() }
|
||||
|
||||
func main() {
|
||||
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
|
||||
}
|
||||
@@ -112,7 +117,11 @@ func runGenerate(args []string, stdout, stderr io.Writer) error {
|
||||
defer i18n.SetLang(previousLang)
|
||||
i18n.SetLang("en")
|
||||
|
||||
snapshot := interfacesnapshot.Capture(app.NewRootCommand())
|
||||
root := newRootCommand()
|
||||
snapshot := interfacesnapshot.Capture(root)
|
||||
if err := validateHelpRendering(root, snapshot); err != nil {
|
||||
return err
|
||||
}
|
||||
if *output == "-" {
|
||||
return interfacesnapshot.Write(stdout, snapshot)
|
||||
}
|
||||
@@ -138,6 +147,16 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
currentPath := flags.String("current", "", "candidate snapshot path")
|
||||
basePath := flags.String("base", "", "target main/development baseline snapshot path")
|
||||
stablePath := flags.String("stable", "", "latest stable GA snapshot path")
|
||||
approvedMigrationsPath := flags.String(
|
||||
"approved-flag-migrations",
|
||||
"",
|
||||
"merge-base-owned approved flag migration manifest",
|
||||
)
|
||||
candidateMigrationsPath := flags.String(
|
||||
"candidate-flag-migrations",
|
||||
"",
|
||||
"candidate flag migration manifest",
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return false, err
|
||||
}
|
||||
@@ -150,6 +169,14 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
if *basePath == "" && *stablePath == "" {
|
||||
return false, fmt.Errorf("compare requires --base, --stable, or both")
|
||||
}
|
||||
if (*approvedMigrationsPath == "") != (*candidateMigrationsPath == "") {
|
||||
return false, fmt.Errorf(
|
||||
"--approved-flag-migrations and --candidate-flag-migrations must be provided together",
|
||||
)
|
||||
}
|
||||
if *approvedMigrationsPath != "" && (*basePath == "" || *stablePath == "") {
|
||||
return false, fmt.Errorf("flag migration compare requires both --base and --stable")
|
||||
}
|
||||
|
||||
current, err := readSnapshot(*currentPath)
|
||||
if err != nil {
|
||||
@@ -170,6 +197,25 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
}
|
||||
|
||||
report := interfacesnapshot.CompareAll(current, references)
|
||||
if *approvedMigrationsPath != "" {
|
||||
approved, readErr := readFlagMigrationManifest(*approvedMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read approved flag migrations: %w", readErr)
|
||||
}
|
||||
candidate, readErr := readFlagMigrationManifest(*candidateMigrationsPath)
|
||||
if readErr != nil {
|
||||
return false, fmt.Errorf("read candidate flag migrations: %w", readErr)
|
||||
}
|
||||
report, err = interfacesnapshot.CompareAllWithFlagMigrations(
|
||||
current,
|
||||
references,
|
||||
approved,
|
||||
candidate,
|
||||
)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("validate flag migration lifecycle: %w", err)
|
||||
}
|
||||
}
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
encoder.SetIndent("", " ")
|
||||
@@ -179,6 +225,49 @@ func runCompare(args []string, stdout, stderr io.Writer) (bool, error) {
|
||||
return report.Compatible, nil
|
||||
}
|
||||
|
||||
func readFlagMigrationManifest(path string) (interfacesnapshot.FlagMigrationManifest, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
return interfacesnapshot.FlagMigrationManifest{}, err
|
||||
}
|
||||
defer file.Close()
|
||||
return interfacesnapshot.ReadFlagMigrationManifest(file)
|
||||
}
|
||||
|
||||
func validateHelpRendering(root *cobra.Command, snapshot interfacesnapshot.Snapshot) error {
|
||||
for _, command := range snapshot.Commands {
|
||||
path := strings.TrimPrefix(command.Path, "dws")
|
||||
resolved, remaining, err := root.Find(strings.Fields(path))
|
||||
if err != nil || len(remaining) != 0 || resolved == nil {
|
||||
return fmt.Errorf("resolve %q before help rendering: remaining=%v error=%v", command.Path, remaining, err)
|
||||
}
|
||||
if err := renderCommandHelp(resolved); err != nil {
|
||||
return fmt.Errorf("render %q help: %w", command.Path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderCommandHelp(command *cobra.Command) (err error) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
command.InitDefaultHelpFlag()
|
||||
command.SetOut(&stdout)
|
||||
command.SetErr(&stderr)
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
err = fmt.Errorf("help renderer panicked: %v", recovered)
|
||||
}
|
||||
}()
|
||||
command.HelpFunc()(command, []string{})
|
||||
if stderr.Len() > 0 {
|
||||
return fmt.Errorf("help renderer wrote an error: %s", strings.TrimSpace(stderr.String()))
|
||||
}
|
||||
if stdout.Len() == 0 {
|
||||
return fmt.Errorf("help renderer produced empty output")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
file, err := os.Open(filepath.Clean(path))
|
||||
if err != nil {
|
||||
@@ -191,5 +280,5 @@ func readSnapshot(path string) (interfacesnapshot.Snapshot, error) {
|
||||
func printUsage(w io.Writer) {
|
||||
fmt.Fprintln(w, "usage:")
|
||||
fmt.Fprintln(w, " interface-snapshot generate [--output FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE]")
|
||||
fmt.Fprintln(w, " interface-snapshot compare --current FILE [--base FILE] [--stable FILE] [--approved-flag-migrations FILE --candidate-flag-migrations FILE]")
|
||||
}
|
||||
|
||||
@@ -15,11 +15,16 @@ package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/interfacesnapshot"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T) {
|
||||
@@ -56,6 +61,24 @@ func TestCrossPlatformCoverageRunGenerateCapturesActualRootOffline(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsHelpRenderingFailure(t *testing.T) {
|
||||
testseam.Swap(t, &newRootCommand, func() *cobra.Command {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
return root
|
||||
})
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run([]string{"generate"}, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(generate) exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "injected help failure") {
|
||||
t.Fatalf("run(generate) stderr=%q", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *testing.T) {
|
||||
current := commandSnapshot("dws")
|
||||
mergeBase := commandSnapshot("dws")
|
||||
@@ -83,6 +106,368 @@ func TestCrossPlatformCoverageRunCompareUsesBothSnapshotInputsAndExitCode(t *tes
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareEnforcesBaseOwnedFlagMigrationLifecycle(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
before := flagMigrationSnapshot(false)
|
||||
after := flagMigrationSnapshot(true)
|
||||
currentPath := writeSnapshot(t, dir, "current.json", after)
|
||||
basePath := writeSnapshot(t, dir, "base.json", before)
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", before)
|
||||
approvedPath := writeManifest(t, dir, "approved.json", flagMigrationManifestJSON("pending"))
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", flagMigrationManifestJSON("consumed"))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 0 {
|
||||
t.Fatalf("exact base-owned migration exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
if !bytes.Contains(stdout.Bytes(), []byte(`"compatible": true`)) {
|
||||
t.Fatalf("exact migration report is not compatible:\n%s", stdout.String())
|
||||
}
|
||||
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
emptyApproved := writeManifest(t, dir, "empty-approved.json", `{"version":1,"migrations":[]}`)
|
||||
exitCode = run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--stable", stablePath,
|
||||
"--approved-flag-migrations", emptyApproved,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must start pending") {
|
||||
t.Fatalf("candidate self-approval exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothFlagMigrationInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", currentPath,
|
||||
"--base", basePath,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "must be provided together") {
|
||||
t.Fatalf("one-sided migration input exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareRequiresBothReferencesForFlagMigrations(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
currentPath := writeSnapshot(t, dir, "current.json", commandSnapshot("dws"))
|
||||
basePath := writeSnapshot(t, dir, "base.json", commandSnapshot("dws"))
|
||||
stablePath := writeSnapshot(t, dir, "stable.json", commandSnapshot("dws"))
|
||||
approvedPath := writeManifest(t, dir, "approved.json", `{"version":1,"migrations":[]}`)
|
||||
candidatePath := writeManifest(t, dir, "candidate.json", `{"version":1,"migrations":[]}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{
|
||||
name: "missing stable",
|
||||
args: []string{"--base", basePath},
|
||||
},
|
||||
{
|
||||
name: "missing base",
|
||||
args: []string{"--stable", stablePath},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
args := []string{"compare", "--current", currentPath}
|
||||
args = append(args, test.args...)
|
||||
args = append(args,
|
||||
"--approved-flag-migrations", approvedPath,
|
||||
"--candidate-flag-migrations", candidatePath,
|
||||
)
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run(args, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "requires both --base and --stable") {
|
||||
t.Fatalf("one-reference migration compare exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPrintsUsageForMissingAndUnknownCommands(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantStderr []string
|
||||
}{
|
||||
{
|
||||
name: "missing command",
|
||||
args: nil,
|
||||
wantStderr: []string{"usage:", "interface-snapshot generate", "--approved-flag-migrations"},
|
||||
},
|
||||
{
|
||||
name: "unknown command",
|
||||
args: []string{"unknown"},
|
||||
wantStderr: []string{`unknown command "unknown"`, "usage:", "interface-snapshot compare"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if stdout.Len() != 0 {
|
||||
t.Fatalf("run(%v) unexpectedly wrote stdout: %s", test.args, stdout.String())
|
||||
}
|
||||
for _, want := range test.wantStderr {
|
||||
if !strings.Contains(stderr.String(), want) {
|
||||
t.Errorf("run(%v) stderr missing %q:\n%s", test.args, want, stderr.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunRejectsInvalidSubcommandArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{name: "generate unknown flag", args: []string{"generate", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "generate positional", args: []string{"generate", "unexpected"}, want: "generate accepts no positional arguments"},
|
||||
{name: "compare unknown flag", args: []string{"compare", "--unknown"}, want: "flag provided but not defined"},
|
||||
{name: "compare positional", args: []string{"compare", "unexpected"}, want: "compare accepts no positional arguments"},
|
||||
{name: "compare missing current", args: []string{"compare", "--base", "base.json"}, want: "compare requires --current"},
|
||||
{name: "compare missing reference", args: []string{"compare", "--current", "current.json"}, want: "compare requires --base, --stable, or both"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(%v) exit=%d, want 2", test.args, exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("run(%v) stderr missing %q:\n%s", test.args, test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateRejectsUnsafeOutputPath(t *testing.T) {
|
||||
outputDirectory := t.TempDir()
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate", "--output", outputDirectory}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create snapshot") {
|
||||
t.Fatalf("directory output exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunGenerateReportsTemporaryDirectoryFailure(t *testing.T) {
|
||||
missingTempRoot := filepath.Join(t.TempDir(), "missing")
|
||||
for _, name := range []string{"TMPDIR", "TMP", "TEMP"} {
|
||||
t.Setenv(name, missingTempRoot)
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{"generate"}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "create isolated home") {
|
||||
t.Fatalf("invalid temporary root exit=%d stdout=%s stderr=%s", exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsSnapshotReadFailures(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
validPath := writeSnapshot(t, dir, "valid.json", commandSnapshot("dws"))
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "current",
|
||||
args: []string{"compare", "--current", missingPath, "--base", validPath},
|
||||
want: "read current snapshot",
|
||||
},
|
||||
{
|
||||
name: "main",
|
||||
args: []string{"compare", "--current", validPath, "--base", missingPath},
|
||||
want: "read main/development baseline snapshot",
|
||||
},
|
||||
{
|
||||
name: "stable",
|
||||
args: []string{"compare", "--current", validPath, "--stable", missingPath},
|
||||
want: "read stable snapshot",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
if exitCode := run(test.args, &stdout, &stderr); exitCode != 2 {
|
||||
t.Fatalf("run(compare) exit=%d, want 2", exitCode)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("stderr missing %q:\n%s", test.want, stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsEachManifestReadFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
validManifest := writeManifest(t, dir, "valid-manifest.json", `{"version":1,"migrations":[]}`)
|
||||
invalidManifest := writeManifest(t, dir, "invalid-manifest.json", `{`)
|
||||
tests := []struct {
|
||||
name string
|
||||
approved string
|
||||
candidate string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "approved manifest",
|
||||
approved: invalidManifest,
|
||||
candidate: validManifest,
|
||||
want: "read approved flag migrations",
|
||||
},
|
||||
{
|
||||
name: "candidate manifest",
|
||||
approved: validManifest,
|
||||
candidate: invalidManifest,
|
||||
want: "read candidate flag migrations",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
"--stable", snapshotPath,
|
||||
"--approved-flag-migrations", test.approved,
|
||||
"--candidate-flag-migrations", test.candidate,
|
||||
}, &stdout, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), test.want) {
|
||||
t.Fatalf("%s exit=%d stdout=%s stderr=%s", test.name, exitCode, stdout.String(), stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunCompareReportsOutputFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
snapshotPath := writeSnapshot(t, dir, "snapshot.json", commandSnapshot("dws"))
|
||||
var stderr bytes.Buffer
|
||||
exitCode := run([]string{
|
||||
"compare",
|
||||
"--current", snapshotPath,
|
||||
"--base", snapshotPath,
|
||||
}, failingWriter{}, &stderr)
|
||||
if exitCode != 2 || !strings.Contains(stderr.String(), "write comparison report") {
|
||||
t.Fatalf("comparison output failure exit=%d stderr=%s", exitCode, stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadHelpersRejectMissingAndInvalidInputs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
missingPath := filepath.Join(dir, "missing.json")
|
||||
invalidPath := filepath.Join(dir, "invalid.json")
|
||||
if err := os.WriteFile(invalidPath, []byte(`{`), 0o600); err != nil {
|
||||
t.Fatalf("write invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if _, err := readSnapshot(missingPath); err == nil {
|
||||
t.Fatal("readSnapshot(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readSnapshot(invalidPath); err == nil {
|
||||
t.Fatal("readSnapshot(invalid) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(missingPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(missing) unexpectedly succeeded")
|
||||
}
|
||||
if _, err := readFlagMigrationManifest(invalidPath); err == nil {
|
||||
t.Fatal("readFlagMigrationManifest(invalid) unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsResolveError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
err := validateHelpRendering(root, commandSnapshot("dws missing"))
|
||||
if err == nil || !strings.Contains(err.Error(), `resolve "dws missing" before help rendering`) {
|
||||
t.Fatalf("validateHelpRendering resolve error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingReportsTemplateError(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate(`{{index .Commands 99}}`)
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRecoversTemplatePanic(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpTemplate("{{")
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), `render "dws" help`) {
|
||||
t.Fatalf("validateHelpRendering template panic = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsCustomHelpStderr(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(command *cobra.Command, _ []string) {
|
||||
_, _ = io.WriteString(command.ErrOrStderr(), "injected help failure")
|
||||
})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "injected help failure") {
|
||||
t.Fatalf("validateHelpRendering custom stderr = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingRejectsEmptyOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
root.SetHelpFunc(func(*cobra.Command, []string) {})
|
||||
err := validateHelpRendering(root, commandSnapshot("dws"))
|
||||
if err == nil || !strings.Contains(err.Error(), "empty") {
|
||||
t.Fatalf("validateHelpRendering empty output = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageValidateHelpRenderingAcceptsNormalOutput(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws", Short: "root command"}
|
||||
if err := validateHelpRendering(root, commandSnapshot("dws")); err != nil {
|
||||
t.Fatalf("validateHelpRendering normal output: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type failingWriter struct{}
|
||||
|
||||
func (failingWriter) Write([]byte) (int, error) {
|
||||
return 0, errors.New("injected write failure")
|
||||
}
|
||||
|
||||
var _ io.Writer = failingWriter{}
|
||||
|
||||
func commandSnapshot(paths ...string) interfacesnapshot.Snapshot {
|
||||
commands := make([]interfacesnapshot.Command, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
@@ -120,6 +505,68 @@ func writeSnapshot(t *testing.T, dir, name string, snapshot interfacesnapshot.Sn
|
||||
return path
|
||||
}
|
||||
|
||||
func writeManifest(t *testing.T, dir, name, contents string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatalf("write %s: %v", path, err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func flagMigrationSnapshot(after bool) interfacesnapshot.Snapshot {
|
||||
legacy := interfacesnapshot.Flag{
|
||||
Name: "legacy-id",
|
||||
Shorthand: "l",
|
||||
Type: "string",
|
||||
Default: "",
|
||||
NoOpt: "auto",
|
||||
Required: true,
|
||||
}
|
||||
flags := []interfacesnapshot.Flag{legacy}
|
||||
if after {
|
||||
legacy.Required = false
|
||||
legacy.Hidden = true
|
||||
legacy.AliasOf = "message-id"
|
||||
flags = []interfacesnapshot.Flag{
|
||||
legacy,
|
||||
{Name: "message-id", Type: "string", Default: "", Required: true},
|
||||
}
|
||||
}
|
||||
return interfacesnapshot.Snapshot{
|
||||
SchemaVersion: interfacesnapshot.SchemaVersion,
|
||||
Rules: interfacesnapshot.Rules{
|
||||
ExcludedCommandSubtrees: []string{},
|
||||
ExcludedFlags: []string{},
|
||||
},
|
||||
Commands: []interfacesnapshot.Command{
|
||||
{Path: "dws", Runnable: true, Aliases: []string{}, LocalFlags: []interfacesnapshot.Flag{}, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
{Path: "dws chat send", Runnable: true, Aliases: []string{}, LocalFlags: flags, InheritedFlags: []interfacesnapshot.Flag{}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func flagMigrationManifestJSON(state string) string {
|
||||
return strings.Replace(`{
|
||||
"version": 1,
|
||||
"migrations": [{
|
||||
"command": "dws chat send",
|
||||
"legacy": {
|
||||
"name": "legacy-id",
|
||||
"before": {"present": true, "type": "string", "required": true, "shorthand": "l", "no_opt": "auto", "scope": "local"},
|
||||
"after": {"present": true, "type": "string", "hidden": true, "shorthand": "l", "no_opt": "auto", "scope": "local", "alias_of": "message-id"}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": {"present": false},
|
||||
"after": {"present": true, "type": "string", "required": true, "scope": "local"}
|
||||
},
|
||||
"state": "STATE",
|
||||
"reason": "reviewed exact migration"
|
||||
}]
|
||||
}`, "STATE", state, 1)
|
||||
}
|
||||
|
||||
func hasFlag(flags []interfacesnapshot.Flag, name, flagType string) bool {
|
||||
for _, flag := range flags {
|
||||
if flag.Name == name && flag.Type == flagType {
|
||||
|
||||
+31
-13
@@ -1,6 +1,6 @@
|
||||
# Architecture
|
||||
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; the embedded Command Catalog serves AI agents.
|
||||
`dws` is a Go CLI with a versioned, static command surface for DingTalk MCP capabilities. Cobra help serves humans; runtime-assembled Schema (`ResolveSchemaBuild`) serves AI agents.
|
||||
|
||||
## High-Level Flow
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
3. `internal/helpers` contains the main command handlers for all product surfaces (`dev`, `chat`, `calendar`, `contact`, `aitable`, etc.).
|
||||
4. `internal/executor` and `internal/transport` execute MCP JSON-RPC calls; `internal/output` formats responses.
|
||||
5. `internal/auth` manages login state, PAT tokens, and agent-code detection.
|
||||
6. Schema generation starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, Agent hints, and Skills into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`.
|
||||
7. The embedded Catalog is a downstream release artifact and never backfills identity or participates in regeneration. Stable flag-to-interface property bindings come from the reviewed, content-addressed v3 manifest in `schema_parameter_bindings.json`; its exact active tuples, corrections, removals, and mapping exclusions are validated against the final bound `SchemaRegistry`. CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
6. Schema assembly (`ResolveSchemaBuild`) starts from the reviewed `CommandRegistry`, binds each identity to the exact current Cobra leaf, and then resolves typed constraints, sanitized MCP snapshots, and leaf ContractFinal / ProductDecl into one `SchemaRegistry`. Startup and Schema queries do not call MCP `tools/list`. There is no generate-written Catalog delivery step.
|
||||
7. Production Catalog / `ResolveMeta` consume the lazily assembled registry via `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` / `deliverySchemaCatalog` (声明即 Catalog; lazy `sync.Once`). `ResolveMeta` projects Identity/Safety/Selection from that assembly into an in-process map cache — not a committed `schema_catalog/` or `schema_meta_index.*` fixture. Flag-to-interface property delivery is owned by leaf `ParamDecl.Property` (native annotations). `schema_parameter_mapping_ledger.go` holds reviewed `mapping_exclusions` / `removals` (the empty `schema_parameter_bindings.json` audit table is retired). CLI `required` and constraints come from the resolved typed contract, while MCP `required` remains interface-only metadata.
|
||||
8. Agent selection results are fixed in versioned review inputs. Every public tool has explicit use/avoid/example and interface disposition metadata; Skill references that are not current leaves require an explicit alias/group/stale/out-of-surface review instead of fuzzy runtime matching.
|
||||
|
||||
## Repository Structure
|
||||
@@ -19,8 +19,8 @@
|
||||
- `internal/app`: root command wiring, static utility commands, and plugin loading
|
||||
- `internal/helpers`: product command handlers (dev, chat, calendar, contact, etc.)
|
||||
- `internal/plugin`: versioned plugin manifest, hook, skill, and transport descriptor loading
|
||||
- `internal/cli`: embedded Agent Command Catalog, static schema query, and catalog contracts
|
||||
- `internal/generator`: deterministic Agent metadata and Command Catalog generators
|
||||
- `internal/cli`: Schema assembly, `dws schema` query, and catalog contracts
|
||||
- `internal/generator`: CI/determinism tools (`cmd_schema_catalog` dump) and param-alias generate
|
||||
- `internal/executor`: invocation dispatch and result handling
|
||||
- `internal/transport`: MCP HTTP client and request signing
|
||||
- `internal/auth`: login, token management, agent-code detection, identity
|
||||
@@ -30,11 +30,16 @@
|
||||
- `internal/security`: endpoint allowlist and domain trust
|
||||
- `internal/safety`: runtime safety checks (confirm prompts, dry-run guards)
|
||||
- `internal/cobracmd`: shared Cobra command builders
|
||||
- `internal/corecmd`: dispatch-agnostic leaf-command base — flag registration,
|
||||
alias/env/default value resolution, required and cross-flag constraint
|
||||
validation, Risk write confirmation, toolArgs assembly, Runtime Schema
|
||||
projection. Distinct from `internal/cobracmd` (generic tree helpers): it owns
|
||||
the declarative leaf contract (`corecmd.Spec`) that the LeafSpec framework is
|
||||
built on and that the Shortcut adapter projects into.
|
||||
- `internal/pat`: PAT (Personal Access Token) authorization flow
|
||||
- `internal/output`: response formatting (json, table, raw, pretty)
|
||||
- `internal/logging`: structured logging and argument sanitization
|
||||
- `internal/tui`: terminal UI helpers
|
||||
- `internal/recovery`: panic recovery and graceful degradation
|
||||
- `pkg/configmeta`: environment variable registry and documentation
|
||||
- `pkg/config`: configuration constants and paths
|
||||
- `pkg/edition`: edition detection (oss vs enterprise)
|
||||
@@ -52,7 +57,13 @@ run.
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
PR["Pull request"] --> CA["CI"]
|
||||
PR["Pull request"] --> CLASSIFY["Fail-closed risk classification"]
|
||||
CLASSIFY --> DOCS["Documentation-only<br/>asset/content validation"]
|
||||
CLASSIFY --> STANDARD["Standard<br/>affected + reverse-dependent race<br/>scope-matched HEAD/base coverage"]
|
||||
CLASSIFY --> HIGH["High-risk / main<br/>full race + native tests"]
|
||||
DOCS --> CA["CI"]
|
||||
STANDARD --> CA
|
||||
HIGH --> CA
|
||||
subgraph CA_CHECKS["Nine required contexts"]
|
||||
L["Lint"]
|
||||
T["Test"]
|
||||
@@ -72,9 +83,16 @@ flowchart TB
|
||||
PLATFORM --> RELEASE
|
||||
```
|
||||
|
||||
Complete Multi-profile E2E and the ordinary full native-platform matrix are
|
||||
downstream of PR admission. PRs still run primary-environment assurance and
|
||||
fast cross-platform compilation; auth, keychain, OS-specific, installer, and
|
||||
release changes additionally select native platform tests before merge. See
|
||||
[`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact context and ruleset
|
||||
contract.
|
||||
All nine named contexts are produced for every tier. Domain-specific helpers
|
||||
run when their owned surface is affected; otherwise the corresponding context
|
||||
records an explicit unaffected success. Standard code changes still receive
|
||||
representative Darwin/Windows compilation. High-risk PRs and protected `main`
|
||||
run the complete race and native test suites, while platform-sensitive diffs
|
||||
also receive native changed-code coverage.
|
||||
|
||||
Review orchestration is also base-owned: it requests one eligible peer without
|
||||
executing PR code, re-routes an updated head when needed, and auto-merge
|
||||
completes only after the latest push has peer approval plus the current
|
||||
revision's nine strict contexts. Complete Multi-profile E2E remains downstream
|
||||
of PR admission. See [`docs/ci-pr-gates.md`](ci-pr-gates.md) for the exact
|
||||
classification, context, reviewer, and ruleset contract.
|
||||
|
||||
+27
-33
@@ -43,7 +43,7 @@ repository root while preserving repo-local guidance for automation.
|
||||
- Error message or category issues: inspect `internal/errors`
|
||||
- Audit log issues: inspect `internal/audit`
|
||||
- Plugin loading or command surface: inspect `internal/plugin`
|
||||
- Failure or degraded mode: inspect `internal/errors`, `internal/recovery`
|
||||
- Failure or degraded mode: inspect `internal/errors`
|
||||
|
||||
## Policy Checks
|
||||
|
||||
@@ -62,32 +62,27 @@ make lint
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Homebrew Formula PR Automation
|
||||
## Homebrew Formula Delivery
|
||||
|
||||
Official tag releases require the repository Actions secret
|
||||
`HOMEBREW_PR_TOKEN`. Prefer a fine-grained personal access token owned by a
|
||||
maintainer or release-bot account, limited to this repository with
|
||||
`Contents: write` and `Pull requests: write`. If organization policy prevents
|
||||
that account from targeting the repository, use a dedicated classic token with
|
||||
only the `public_repo` scope. Do not reuse a broad developer token.
|
||||
Official releases use the Release workflow's built-in `GITHUB_TOKEN` 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
|
||||
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
|
||||
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.
|
||||
|
||||
Store the dedicated token as the `HOMEBREW_PR_TOKEN` repository Actions secret
|
||||
and rotate it before its configured expiration. Replace it immediately if it is
|
||||
exposed, its owner loses repository access, or the release-bot ownership
|
||||
changes. The Release workflow uses this
|
||||
dedicated token only to push an `automation/homebrew-*` branch and open the
|
||||
stable or beta Formula PR. It does not push Formula changes directly to `main`.
|
||||
The default-branch governance preflight and every tag contract authenticate the
|
||||
token before publication, reject over-scoped classic tokens, confirm its
|
||||
identity, and run a controlled write canary. The canary pushes a unique
|
||||
`automation/homebrew-token-canary-*` branch with a `[skip ci]` commit, creates a
|
||||
draft PR, closes it, and deletes the branch with the same token. This proves both
|
||||
Contents and Pull requests write access before publication without merging
|
||||
anything. The gate also rejects reuse of `RELEASE_GOVERNANCE_TOKEN`.
|
||||
No maintainer environment variable is required when creating a tag. Using the
|
||||
built-in `GITHUB_TOKEN` is insufficient because organization policy prevents
|
||||
Actions from creating pull requests, and its generated PR events may require
|
||||
separate workflow approval.
|
||||
Keep `HOMEBREW_PR_TOKEN` repository-scoped with `Contents: write` and
|
||||
`Pull requests: write` (the latter remains necessary for withdrawal rollback),
|
||||
keep its owner as the designated ruleset bypass actor, and do not reuse
|
||||
`RELEASE_GOVERNANCE_TOKEN`. The workflow and publisher provide the Formula-only
|
||||
path restriction; GitHub rulesets do not infer that restriction from the token.
|
||||
|
||||
## Release Governance and Recovery
|
||||
|
||||
@@ -98,15 +93,14 @@ administration setting and cannot be read by the workflow's built-in
|
||||
contract use this same credential so a missing or expired identity is detected
|
||||
before an irreversible tag is created.
|
||||
|
||||
Create a protected `release-recovery` environment limited to protected
|
||||
branches, with a required reviewer, self-review disabled, and administrator
|
||||
bypass disabled. The workflow reads the environment through the GitHub API and
|
||||
fails closed unless the required-reviewer, prevent-self-review, and protected-
|
||||
branch rules are present.
|
||||
Recovery is restricted to an existing annotated tag whose exact tag object,
|
||||
commit, and failed tag-push run all match; it then reuses the normal release
|
||||
jobs. Do not put publication secrets in temporary branches or create ad-hoc
|
||||
recovery workflows.
|
||||
commit, sealed metadata, original failed run/attempt, requester identity and
|
||||
Release state all match; it then reuses the normal release jobs without a
|
||||
second-person environment approval. A same-run “Re-run failed jobs” is even
|
||||
lighter: the seal job may adopt an existing tag only when its complete
|
||||
authority matches that run and its original attempt is not newer than the
|
||||
current attempt. Do not put publication secrets in temporary branches or
|
||||
create ad-hoc recovery workflows.
|
||||
|
||||
Cloud-sealed releases mirror to OSS only when the repository variable
|
||||
`ENABLE_OSS_MIRROR` is exactly `true`. Leave the variable unset while no Bucket
|
||||
|
||||
+119
-28
@@ -4,9 +4,9 @@ The pull-request admission layer has exactly nine required external contexts:
|
||||
|
||||
| Required context | Contract |
|
||||
|---|---|
|
||||
| `Lint` | Stable PR revision classification, formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Race/unit/release-script tests plus fast cross-platform compilation |
|
||||
| `Coverage` | Overall non-regression and 100% changed-code coverage |
|
||||
| `Lint` | Stable PR revision/risk classification plus applicable formatting, `go vet`, and Actionlint |
|
||||
| `Test` | Tier-selected race/unit/release-script tests plus representative cross-platform compilation |
|
||||
| `Coverage` | Scope-matched overall non-regression and 100% changed-code coverage |
|
||||
| `Policy` | Repository policy and the fail-closed CHANGELOG contract |
|
||||
| `Edition` | Edition contract tests |
|
||||
| `Interface Integrity` | CLI, Schema, Skill, and stable-release compatibility |
|
||||
@@ -56,8 +56,27 @@ base notes into an invalid final CHANGELOG.
|
||||
|
||||
All nine admission contexts are still emitted and must succeed. Expensive
|
||||
implementation helpers are skipped; the named contexts record that their code
|
||||
surface is unaffected. After merge, the protected `main` push executes the
|
||||
full admission suite.
|
||||
surface is unaffected.
|
||||
|
||||
The protected `main` push keeps that fast path only when all of these
|
||||
fail-closed conditions hold:
|
||||
|
||||
- the event is a non-forced update of the existing `refs/heads/main`;
|
||||
- the event `after` SHA is the exact workflow SHA, and both event SHAs are
|
||||
complete, non-zero commit IDs;
|
||||
- GitHub's comparison reports the previous main tip as the unique linear merge
|
||||
base, with no commits behind it;
|
||||
- the complete resulting tree diff is exactly one in-place modification of
|
||||
`CHANGELOG.md`;
|
||||
- the previous main tip already has successful GitHub Actions checks for all
|
||||
nine Code Admission contexts.
|
||||
|
||||
`Policy` then independently checks out the pushed revision and runs the same
|
||||
`check-changelog-pr.sh --fast-path` contract from the event's `before` SHA to
|
||||
its `after` SHA. If identity, ancestry, file scope, tree mode, CHANGELOG
|
||||
content, or predecessor admission cannot be proved, classification falls back
|
||||
to the complete main admission suite. A source change can therefore never
|
||||
inherit the CHANGELOG-only result.
|
||||
|
||||
Any PR that touches `CHANGELOG.md` but also changes another file runs the same
|
||||
content contract in `Policy` with `--content-only`. That mode permits the
|
||||
@@ -65,13 +84,28 @@ second file but still rejects invalid dates or versions, missing bullets,
|
||||
placeholder `TODO`/`TBD`, unmanaged-section changes, and unsafe tree modes.
|
||||
Adding a second file therefore cannot bypass CHANGELOG validation.
|
||||
|
||||
## Platform and downstream boundaries
|
||||
## Risk tiers and downstream boundaries
|
||||
|
||||
Ordinary PRs run the primary Linux assurance plus fast Darwin/Windows compile
|
||||
checks. Full native macOS/Windows tests and platform coverage run on a PR only
|
||||
when its diff touches auth, keychain, OS-specific Go files, installers,
|
||||
packaging, Formulae, or release automation. Protected `main` pushes run the
|
||||
complete native matrix.
|
||||
`Lint` resolves the complete base/head diff before any helper is skipped.
|
||||
Unknown or truncated input fails closed into the high-risk tier.
|
||||
|
||||
| Tier | Selection | Admission work |
|
||||
|---|---|---|
|
||||
| Documentation-only | Only prose/documentation assets; no executable, generated, workflow, packaging, or interface surface | Documentation and repository-asset validation; expensive code helpers skip while every required context still succeeds |
|
||||
| Standard | Ordinary code change with a stable package graph | Race tests for changed Go packages and their reverse dependencies; candidate and merge-base coverage over the same impacted scope and `coverpkg`; representative Darwin/Windows compilation |
|
||||
| High-risk / protected `main` | Workflow/policy, package add/remove/rename, generated Schema/registry, platform, auth/keychain, installer, packaging, release, transport, recovery, or an unprovable infrastructure classification | Complete race suite and full native macOS/Windows tests, plus every affected domain gate |
|
||||
|
||||
Domain helpers (`Edition`, `Interface Integrity`, `CLI Smoke`, and `Mock MCP`,
|
||||
for example) execute their substantive suites when the diff can affect that
|
||||
contract or when the high-risk tier is selected. Otherwise their stable named
|
||||
contexts still report a successful, explicit unaffected result. Release-script
|
||||
tests follow the same impact rule. This preserves the ruleset contract without
|
||||
charging every developer for unrelated work.
|
||||
|
||||
Platform-sensitive changes additionally run native changed-code coverage.
|
||||
Protected `main` always runs native tests; generic portable changes are held to
|
||||
the Linux changed-code gate rather than being forced to manufacture
|
||||
platform-only coverage.
|
||||
|
||||
Complete `Multi-profile E2E` is not a PR admission context. It belongs to the
|
||||
`Main Integration — 主干集成` workflow and runs only after a push to `main` (or
|
||||
@@ -97,22 +131,59 @@ flowchart TB
|
||||
MAIN --> RELEASE["Release delivery"]
|
||||
```
|
||||
|
||||
## Review ownership and auto-merge
|
||||
|
||||
A base-owned `pull_request_target` workflow routes newly opened, updated,
|
||||
reopened, or newly ready PRs targeting `main` to one eligible peer reviewer. It
|
||||
does not check out or execute PR code, excludes both the author and the known
|
||||
latest pusher, and balances the open requested-review load across the reviewed
|
||||
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.
|
||||
|
||||
## Running focused gates locally
|
||||
|
||||
Run the contracts relevant to the change:
|
||||
Run the contracts relevant to the change. Ordinary contributors are not
|
||||
expected to repeat every CI job locally:
|
||||
|
||||
```sh
|
||||
make build
|
||||
make policy
|
||||
make interface-integrity
|
||||
make authoritative-interface-integrity BASE_REF=<merge-base>
|
||||
make schema-compatibility BASE_REF=<merge-base>
|
||||
make interface-integrity BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make schema-compatibility BASE_REF=<merge-base> STABLE_REF=<stable-tag> CANDIDATE_REF=<candidate-sha>
|
||||
make skill-command-integrity
|
||||
make cli-smoke
|
||||
make mock-mcp-smoke
|
||||
go test -v -count=1 ./pkg/editiontest/...
|
||||
```
|
||||
|
||||
CI 先解析并核对精确的 merge-base、最近可达且未撤回的 stable GA tag 和已提交的 candidate
|
||||
SHA,再调用 `make authoritative-interface-integrity`。本地 `make interface-integrity`
|
||||
与该 CI target 都只委托给同一个 modern authoritative wrapper,不存在第二个比较
|
||||
入口。省略 `BASE_REF` 时本地 target 默认比较 `origin/main`,省略 `STABLE_REF` 时自动
|
||||
选择该 base 可达且未撤回的最近 stable GA tag,省略 `CANDIDATE_REF` 时比较已提交的 `HEAD`。
|
||||
需要逐字复现某次 CI 时,应显式传入该次运行记录的 merge-base、stable tag 和
|
||||
candidate SHA。
|
||||
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 只维护
|
||||
`test/fixtures/cli-interface-baseline.txt` 这一份非权威 CLI Smoke fixture。底层旧
|
||||
`check-interface-baseline.sh` 不再作为本地或 CI 的兼容性审批入口,也不能用于批准
|
||||
flag 迁移。
|
||||
|
||||
Schema compatibility 使用同一组 base、stable、candidate refs 和同一份 base-owned flag
|
||||
migration ledger。merge-base-owned checker 分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;它只把已通过 Interface
|
||||
lifecycle 的 exact rename 规范化到当前历史副本,不会维护第二份 allowlist,也不会
|
||||
放宽其他 Schema 历史字段。
|
||||
|
||||
For an exact CHANGELOG-only branch:
|
||||
|
||||
```sh
|
||||
@@ -120,21 +191,37 @@ base_ref=$(git merge-base HEAD origin/main)
|
||||
./scripts/policy/check-changelog-pr.sh --fast-path "$base_ref" HEAD
|
||||
```
|
||||
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. CI
|
||||
generates the candidate, supporting, merge-base, and (when risk-selected)
|
||||
native profiles before the aggregate `Coverage` context evaluates them. The
|
||||
`make coverage-gate` is an enforcement step, not a profile generator. For a
|
||||
standard PR, CI derives changed packages and their reverse-dependency test
|
||||
closure, then generates candidate and merge-base profiles with the same test
|
||||
scope and `coverpkg`. High-risk and protected-main runs use the complete
|
||||
profiles. 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
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance merge-base
|
||||
non-regression check. Candidate and baseline profiles are evaluated by the
|
||||
same block-deduplicating checker; supporting policy and shortcut profiles
|
||||
contribute to changed-code coverage only. The checked-in badge is presentation
|
||||
only and is never read as a gate input.
|
||||
statements. Overall coverage remains an unrounded, zero-tolerance,
|
||||
scope-matched merge-base non-regression check. Candidate and baseline profiles
|
||||
are evaluated by the same block-deduplicating checker; supporting policy and
|
||||
shortcut profiles contribute to changed-code coverage only. The checked-in
|
||||
badge is presentation only and is never read as a gate input.
|
||||
|
||||
Compatibility checks derive authoritative Interface snapshots from the PR
|
||||
merge-base and the latest reachable stable release. The candidate cannot bless
|
||||
a breaking change by editing a fixture. Schema additions are allowed;
|
||||
historical products, tools, parameters, mappings, positional execution fields,
|
||||
constraints, and safety semantics remain protected.
|
||||
CLI 兼容检查只使用 modern Interface Snapshot 这一处权威比较 seam,并从 PR
|
||||
merge-base 和最近的可达 stable release 生成权威快照。本治理机制合入后,
|
||||
merge-base 拥有生成器、比较器和已审批迁移清单,因此 candidate 不能通过修改
|
||||
helper、fixture 或在同一 PR 新增 self-approval 记录来放行 breaking change。首次
|
||||
bootstrap 仍由 merge-base 已有的 modern helper 做无豁免比较,并只接受 candidate
|
||||
提交中的规范空清单;完整边界见下方治理文档。
|
||||
|
||||
精确的两阶段 flag 迁移生命周期见
|
||||
[CLI flag 兼容迁移治理](cli-interface-flag-migrations.md)。治理 PR 只能在
|
||||
surface 未变化时新增 `pending`;后续产品 PR 达到审批的精确 surface 后,才能
|
||||
消费 base-owned 记录并改为 `consumed`。在 main 与 stable 都达到 after 状态前
|
||||
必须保留该回执,之后再由单独 PR 清理。机制只放行记录中的 legacy
|
||||
visible-to-hidden,以及 canonical required 新增或提升;删除、type、scope、
|
||||
shorthand、no-opt 和任何无关漂移仍然阻塞。Schema 可以新增;历史 product、
|
||||
tool、parameter、mapping、positional execution、constraint 与 safety 语义继续
|
||||
受保护。`alias_of` 只是一项由 `FlagSpec.Aliases` 产生的框架关系证据,不是 payload
|
||||
等价证明;产品 PR 仍须证明 canonical 与 legacy 的最终运行 payload 等价并在 transport
|
||||
前拒绝冲突输入。当前迁移清单为空,不授权 PR #904。
|
||||
|
||||
## Required GitHub repository settings
|
||||
|
||||
@@ -156,3 +243,7 @@ Do not require helper jobs, `Multi-profile E2E`, or an aggregate admission
|
||||
alias. Update ruleset contexts only after the new names have appeared on the
|
||||
protected branch, so a rename cannot silently remove enforcement or leave an
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
# CLI flag 兼容迁移治理
|
||||
|
||||
本文定义一种受控迁移:保留旧 flag 的可执行兼容性,但把它从 Help 与 Agent Schema 中隐藏,并将新的规范 flag 提升为必填。它只解决这一种精确变更,不是通用 breaking-change 豁免。
|
||||
|
||||
同名 flag 的精确类型迁移属于另一类评审机制,只能进入
|
||||
`internal/interfacesnapshot/reviewed.go` 与 legacy smoke helper 的镜像表;flag rename
|
||||
只能进入本文的 JSON lifecycle ledger。一项迁移不得跨两种机制组合授权。
|
||||
|
||||
## 唯一比较入口与信任边界
|
||||
|
||||
PR 与本地兼容性审批的唯一权威比较入口是 modern Interface Snapshot:
|
||||
|
||||
- `cmd/interface-snapshot` 生成和比较快照;
|
||||
- `internal/interfacesnapshot` 实现兼容规则和迁移生命周期;
|
||||
- `scripts/policy/check-command-compatibility.sh` 组装 candidate、PR merge-base 和最近可达且未撤回的 stable GA 三份快照;
|
||||
- `scripts/policy/check-authoritative-interface-baselines.sh` 只保留为 Makefile 的兼容包装,不再维护第二套判断逻辑。
|
||||
|
||||
紧随其后的 Schema compatibility 不是第二份审批清单。它从同一 merge-base-owned
|
||||
ledger 和同一组三方 Interface Snapshot 取得已经完成 lifecycle 校验的
|
||||
authorization。merge-base-owned checker 会分别规范化 merge-base 与 stable 的完整
|
||||
Schema,并让 candidate 对两份历史 contract 独立执行检查;授权的 flag rename 只会
|
||||
精确投影到当前被检查的历史副本。candidate 不能为 CLI 与 Schema 分别提供两套例外。
|
||||
|
||||
`make interface-integrity` 也调用上述 authoritative wrapper;默认 base 为
|
||||
`origin/main`,stable 可由包装脚本自动解析,candidate 默认为已提交的 `HEAD`。旧
|
||||
`scripts/policy/check-interface-baseline.sh` 只供
|
||||
`make update-interface-baseline` / `make reset-interface-baseline` 维护非权威 CLI
|
||||
Smoke fixture,不参与迁移审批。
|
||||
|
||||
直接调用 `interface-snapshot compare` 时,只要提供 migration manifest 参数,就必须
|
||||
同时提供 `--base` 与 `--stable`;核心 lifecycle 也拒绝缺失 stable 的非空清单,避免
|
||||
调用方因漏传历史参考而提前清理 consumed receipt。
|
||||
|
||||
PR merge-base 同时拥有快照生成器、比较器和已审批清单。门禁用这套 base-owned helper 检查同一个已提交 candidate revision、merge-base 与 stable,candidate 不能通过修改自己的 Go 比较 helper 来放宽规则。candidate 中的清单只参与迁移状态流转,不能批准同一个 PR 引入的接口变化。首次引入本机制时,merge-base 尚无迁移解析器;bootstrap 会用 merge-base 已有的 modern Interface Snapshot 做不带豁免的普通比较,并只接受 candidate 中逐字匹配的空清单,不会让 candidate 新增的 comparator 决定本 PR 是否兼容。bootstrap 无法让旧 helper 证明新治理实现本身正确,因此本治理 PR 的新 parser、lifecycle、launcher 与 hostile tests 仍是必须由真人评审的受保护策略变更;它们合入后才成为后续 PR 的 base-owned authority。
|
||||
|
||||
这条边界保护比较规则和审批数据,不是任意代码沙箱。GitHub workflow / launcher 的变更仍由仓库保护规则和真人评审负责;candidate Cobra 构建也会执行 candidate 代码,因此对同一 runner 上的主动恶意代码,需要独立进程或文件系统隔离,不能把本门禁描述成已经解决。
|
||||
|
||||
已审批清单固定为:
|
||||
|
||||
```text
|
||||
scripts/policy/interface-migrations/approved-flag-migrations-v1.json
|
||||
```
|
||||
|
||||
清单使用严格 JSON 解析:版本、字段名大小写、JSON 值类型、命令路径和 flag 名都必须精确;拒绝重复键、未知键、scalar `null` 与尾随 JSON 值,`reason` 不能为空;禁止 `*`、`?`、前缀规则或其他 wildcard。当前清单为空,因此本治理 PR **不授权 PR #904 或任何产品接口变化**。
|
||||
|
||||
## 两阶段迁移与回执清理
|
||||
|
||||
每条迁移以 `(command, legacy flag, canonical 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 达到记录的必填状态 |
|
||||
| 3. 保留回执 | 产品 PR 合入后,如果 stable 仍是 `before`,继续保留 `consumed` | merge-base 或 stable 仍有任一份尚未达到 `after` |
|
||||
| 4. 单独清理 | 当 merge-base 和 stable 都已经是 `after`,在后续 PR 删除该记录 | 两份参考快照均精确匹配 `after`;继续保留过期回执会被门禁拒绝 |
|
||||
|
||||
因此,新增 `pending` 和修改产品 surface 不能发生在同一个 PR;candidate 自己新增的记录不能 self-approve。迁移也不能部分执行:legacy、canonical、`alias_of` 或状态只要有一项不匹配,门禁即失败。
|
||||
|
||||
下面只是清单结构示例,不代表已审批命令;实际字段必须从 Interface Snapshot 核对:
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"migrations": [
|
||||
{
|
||||
"command": "dws chat message recall",
|
||||
"legacy": {
|
||||
"name": "msg-id",
|
||||
"before": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
},
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"hidden": true,
|
||||
"scope": "local",
|
||||
"alias_of": "message-id"
|
||||
}
|
||||
},
|
||||
"canonical": {
|
||||
"name": "message-id",
|
||||
"before": { "present": false },
|
||||
"after": {
|
||||
"present": true,
|
||||
"type": "string",
|
||||
"required": true,
|
||||
"scope": "local"
|
||||
}
|
||||
},
|
||||
"state": "pending",
|
||||
"reason": "保留旧 argv 兼容性,并将规范 flag 设为唯一可见入口"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
产品迁移 PR 必须保持同一条记录的命令、flag、before/after 和 reason 不变,只把 `pending` 改成 `consumed`。
|
||||
|
||||
## `alias_of` 是框架来源的受评审关系证据
|
||||
|
||||
`alias_of` 不是 Schema 同义词、参数概念词典或任意文字声明。它只能由 `FlagSpec.Aliases` 写入,并与内部 origin `corecmd.flag_spec_aliases.v1` 成对出现;每次 Interface Integrity 都会在已提交的 detached candidate 上执行源码门禁,禁止其他生产文件写入或复刻这些 evidence token。Interface Snapshot 会验证:
|
||||
|
||||
- legacy 与 canonical 位于同一个可执行命令;
|
||||
- canonical flag 确实存在;
|
||||
- legacy 与 canonical 类型一致;
|
||||
- legacy 不是指向自身,也不存在 alias chain;
|
||||
- legacy 的 after 状态精确指向该记录中的 canonical flag。
|
||||
|
||||
通过命令框架声明 `FlagSpec.Aliases` 时,框架会自动注册隐藏的兼容 flag,并写入两项 relation annotation;仅手写 `alias_of`、伪造 origin、重复值或不精确值都会让快照生成失败。不要用 Schema overlay、迁移清单或手写 Cobra annotation 伪造关系。
|
||||
|
||||
这项关系证据只证明 legacy/canonical 经过受控框架路径建立关系,不证明最终 transport payload 等价,也不会替产品代码实现命令特有的值同步。当前框架还禁止把
|
||||
`MarkRequired` 与 `FlagSpec.Aliases` 直接组合,因为 Cobra 的 hard-required
|
||||
校验只识别 canonical spelling。若产品迁移同时需要 canonical 的 Cobra required
|
||||
标记和 legacy spelling,产品 PR 必须提供明确的运行时方案,并通过 canonical / legacy
|
||||
最终 payload 等价、同值输入一致、冲突输入在 transport 前失败、legacy 仍可调用但 Help 隐藏等测试;迁移清单和 relation evidence 都不能替代这些证明。
|
||||
|
||||
## 豁免边界
|
||||
|
||||
一条 base-owned、状态正确且前后快照精确匹配的记录,只会从普通兼容报告中移除以下两类预期 finding:
|
||||
|
||||
1. legacy flag 的 `flag_became_hidden`(visible → hidden);
|
||||
2. canonical flag 的 `required_flag_added`(新增时即必填)或 `flag_became_required`(已有 flag 从可选变必填)。
|
||||
|
||||
以下变化仍按普通兼容规则阻塞,不能被迁移记录掩盖:
|
||||
|
||||
- 删除 legacy、canonical、命令或其他 flag;
|
||||
- flag 类型或迁移记录中的 scope、shorthand、`no_opt` 漂移;
|
||||
- `alias_of` 缺失、指向变化或 alias chain;
|
||||
- 命令路径及任何无关的阻塞性接口变化;
|
||||
- 不精确、部分完成、超出记录范围的 surface 变化。
|
||||
|
||||
## Schema 投影边界
|
||||
|
||||
Agent-visible command 会把 visible Cobra flag 投影为 Schema parameter,因此合法的
|
||||
legacy hidden 迁移会同时表现为历史 parameter 消失,constraint member 也可能从
|
||||
legacy 名改为 canonical 名。Schema adapter 只接受已经由三方 Interface Snapshot
|
||||
判定为 authorized 的迁移,并按 tool 的精确 `primary_cli_path` 绑定:
|
||||
|
||||
- reference 仍处于 `before` 且该 flag 有 Schema surface 时,baseline legacy parameter
|
||||
必须存在,candidate legacy parameter 必须消失,candidate canonical parameter 必须存在;
|
||||
如果 baseline 只有 canonical、没有 legacy,则 adapter 不得借 CLI ledger 提升
|
||||
`required` / `cli_required` 或重写 constraint;
|
||||
- rename 前后的 `type`、`property`、`interface_type`、default、format、enum 与
|
||||
`required_when` 必须完全一致;
|
||||
- `required` / `cli_required` 只能保持不变或按审批从 `false` 提升为 `true`,禁止降低;
|
||||
- constraint 只允许在同一 tool 内按已枚举的 legacy → canonical map 做 member 替换、
|
||||
排序与去重;group kind、非迁移 member 或 group 增删仍然阻塞;
|
||||
- 多个 legacy 指向同一 canonical 时,所有历史 parameter signature 必须一致,否则
|
||||
fail closed。
|
||||
|
||||
adapter 先构造经过上述验证的历史 contract 副本,再调用原 Schema checker;它不会按
|
||||
错误字符串删除 finding。这样既能处理纯 rename,也能阻止“旧 required 参数改名后意外
|
||||
变为 optional”或 property 漂移等伪兼容。`consumed` 回执在 merge-base Schema 已经处于
|
||||
canonical-only `after` 状态时不需要再次投影;adapter 保持 baseline 不变,由原 checker
|
||||
验证 candidate 是否仍与该 canonical contract 兼容。
|
||||
|
||||
## 本地验证
|
||||
|
||||
先确保 merge-base 和 stable tag 已在本地,然后运行与 CI 相同的权威门禁:
|
||||
|
||||
```sh
|
||||
make interface-integrity \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
|
||||
make schema-compatibility \
|
||||
BASE_REF=<merge-base> \
|
||||
STABLE_REF=<stable-tag> \
|
||||
CANDIDATE_REF=<candidate-sha>
|
||||
```
|
||||
|
||||
`STABLE_REF` 必须解析到从该 merge-base 可达的最高未撤回 stable GA tag;primary checker 会按 release contract 独立核对,不能用任意 after commit 或已撤回版本提前清理回执。包装脚本可以在省略时自动解析。`CANDIDATE_REF` 省略时固定为命令启动时的已提交 `HEAD`;评审和复现 CI 时应显式传入 candidate SHA,避免 surface 与清单来自不同 revision。
|
||||
@@ -0,0 +1,209 @@
|
||||
# command 领域模型
|
||||
|
||||
本文档描述 `internal/corecmd` 包的领域模型——类型、概念及其关系。
|
||||
|
||||
## 核心模型图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ corecmd.Spec │
|
||||
│ (一个叶子命令的完整契约) │
|
||||
├─────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌─── CLI 表面 ───┐ ┌─── 参数声明 ───────────────────────────┐ │
|
||||
│ │ Use │ │ FlagSpec[] │ │
|
||||
│ │ Short │ │ ├─ Name / Kind / Default │ │
|
||||
│ │ Long │ │ ├─ Required / MarkRequired │ │
|
||||
│ │ Example │ │ ├─ Aliases[] / EnvVar (回退链) │ │
|
||||
│ └────────────────┘ │ ├─ Bind / Transform / OmitEmpty │ │
|
||||
│ │ └─ Enum / Format / SchemaDescription │ │
|
||||
│ │ │ │
|
||||
│ │ Constraint[] │ │
|
||||
│ │ ├─ at_least_one │ │
|
||||
│ │ ├─ exactly_one │ │
|
||||
│ │ └─ mutually_exclusive │ │
|
||||
│ │ │ │
|
||||
│ │ ConstParams map[string]any │ │
|
||||
│ └────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 安全模型 ──────────────────────────────────────────────────┐ │
|
||||
│ │ Safety contract.SafetySpec │ │
|
||||
│ │ ├─ Effect (read / write / destructive) │ │
|
||||
│ │ ├─ Risk (low / medium / high) │ │
|
||||
│ │ ├─ Confirmation (not_required / user_required) ──▶ 运行时门 │ │
|
||||
│ │ └─ Idempotency (idempotent / retryable / …) │ │
|
||||
│ │ │ │
|
||||
│ │ 四字段彼此独立;同一值同时供运行时与 Schema 使用 │ │
|
||||
│ │ ConfirmFirst: bool (只控制确认门顺序) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── Contract 声明 (Agent 可见的元数据) ────────────────────────┐ │
|
||||
│ │ ContractDecl │ │
|
||||
│ │ ├─ Title / Description │ │
|
||||
│ │ ├─ contract.DryRunSpec {PreviewKind, RemoteReads} │ │
|
||||
│ │ ├─ contract.InterfaceSpec {Mode, Availability, Reason, Ref}│ │
|
||||
│ │ ├─ contract.SelectionSpec {AgentSummary, UseWhen, AvoidWhen│ │
|
||||
│ │ │ Prerequisites, Tips, Examples} │ │
|
||||
│ │ ├─ contract.ToolIdentitySpec {ProductID, CanonicalPath, …} │ │
|
||||
│ │ └─ Positionals[] {Name, Type, Required, Variadic} │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 执行体 (恰好一个) ─────────────────────────────────────────┐ │
|
||||
│ │ Invoke(Ctx, toolArgs) ← #830 过渡:单步派发(目标 mcpbind)│ │
|
||||
│ │ Orchestrate(Ctx) ← #830 过渡:多步编排(目标 Handler)│ │
|
||||
│ │ RunE(cmd, args) ← 逃生舱:完全自定义 │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ┌─── 钩子 ─────────────────────────────────────────────────────┐ │
|
||||
│ │ Validate(cmd, args) ← 条件式业务校验(约束表达不了的) │ │
|
||||
│ │ PostMount(cmd) ← 挂载收尾(设置 Args 等 cobra 属性) │ │
|
||||
│ └───────────────────────────────────────────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## 构建与执行流
|
||||
|
||||
```
|
||||
corecmd.Spec ──── corecmd.New() ────▶ cobra.Command ──── 用户执行 ────▶ Ctx
|
||||
│ │ │
|
||||
构建时检查: 注册产物: 执行上下文:
|
||||
• validateDispatchDecl • Flags + Aliases • Str(name)
|
||||
• validateSafetySpec • Annotations (Schema) • Int(name)
|
||||
• validateContractDecl • Long (约束 help) • Bool(name)
|
||||
• RegisterFlags • RunE (管线) • StrSlice(name)
|
||||
• ValidateConstraintDecls • Changed(name)
|
||||
• embedContractIntoSchema • DryRun() / Yes()
|
||||
• AnnotateConstraints
|
||||
• PostMount
|
||||
```
|
||||
|
||||
## 领域概念
|
||||
|
||||
| 概念 | 类型 | 职责 |
|
||||
|------|------|------|
|
||||
| **corecmd.Spec** | struct | 一个命令的完整契约(声明 + 执行) |
|
||||
| **FlagSpec** | struct | 一个参数的注册、回退链、绑定规则 |
|
||||
| **Constraint** | struct | 参数间的关系约束 |
|
||||
| **Safety** | contract.SafetySpec | 运行时与 Schema 共用的安全契约 |
|
||||
| **ContractDecl** | struct | Agent 可见的完整工具规格声明 |
|
||||
| **contract.SelectionSpec** | struct | Agent 选择该工具的语义指引 |
|
||||
| **contract.InterfaceSpec** | struct | 工具的接口模式与可用性 |
|
||||
| **contract.DryRunSpec** | struct | dry-run 能力声明 |
|
||||
| **contract.ToolIdentitySpec** | struct | 工具在注册表中的身份标识 |
|
||||
| **contract.RuntimeSchemaPositional** | struct | 有序位置参数声明 |
|
||||
| **Ctx** | struct | 执行上下文(类型安全的 flag 读取) |
|
||||
| **New** | func | 统一构建器(`corecmd.Spec` → `*cobra.Command`) |
|
||||
|
||||
## SafetySpec
|
||||
|
||||
`corecmd.Spec.Safety` 使用 `corecmd/contract.SafetySpec`(无 cli 类型别名),没有 command 自定义 Risk/Safety 枚举,也没有 `SafetyDecl` 覆盖层:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write",
|
||||
Risk: "high",
|
||||
Confirmation: "user_required",
|
||||
Idempotency: "unknown",
|
||||
}
|
||||
```
|
||||
|
||||
- `Confirmation == "user_required"` 时执行确认;`--yes` 和 `--dry-run` 可跳过交互。
|
||||
- `Effect`、`Risk`、`Idempotency` 不参与确认决策,也不会改写 `Confirmation`。
|
||||
- 任意一个字段非空时,四个字段必须全部显式声明;构建时拒绝部分声明。
|
||||
- 完全空值仅作为历史只读默认,最终发布为 `read/low/not_required/idempotent`。
|
||||
|
||||
## FlagSpec 有效值回退链
|
||||
|
||||
框架统一的 flag 值解析顺序:
|
||||
|
||||
```
|
||||
显式主 flag (Changed)
|
||||
│ 空?
|
||||
▼
|
||||
隐藏别名 (Changed, 按声明序)
|
||||
│ 空?
|
||||
▼
|
||||
环境变量 (EnvVar)
|
||||
│ 空?
|
||||
▼
|
||||
注册默认值 (Default)
|
||||
│ 空?
|
||||
▼
|
||||
ArgDefault (兜底)
|
||||
```
|
||||
|
||||
各 Kind 的特殊行为:
|
||||
|
||||
| Kind | 入参条件 | 回退链 |
|
||||
|------|----------|--------|
|
||||
| KindString | 有效值非空(或 !OmitEmpty) | 完整参与 |
|
||||
| KindInt | 值 ≠ 0(putInt 语义) | 完整参与 |
|
||||
| KindBool | Changed 时入参(显式 false 也下发) | 不参与别名/env 回退 |
|
||||
| KindStringSlice | 存在非空元素 | 仅 Changed 的主 flag/alias |
|
||||
|
||||
## Constraint 约束
|
||||
|
||||
声明式跨 flag 关系,构建时校验合法性,运行时统一执行:
|
||||
|
||||
| Kind | 语义 | 错误文案示例 |
|
||||
|------|------|-------------|
|
||||
| `at_least_one` | 至少提供一个 | "请至少指定 --a、--b 之一" |
|
||||
| `exactly_one` | 恰好提供一个 | "请指定 --a、--b 之一" / "只能指定其一" |
|
||||
| `mutually_exclusive` | 最多提供一个 | "参数 --a、--b 互斥,只能指定其一" |
|
||||
|
||||
"是否提供"的判定复用有效值回退链(显式主 flag → 别名 → env),注册默认值不算作已提供。
|
||||
|
||||
## ContractDecl 子结构
|
||||
|
||||
### contract.SelectionSpec(Agent 选择指引)
|
||||
|
||||
```go
|
||||
contract.SelectionSpec{
|
||||
AgentSummary: "一句话描述工具做什么",
|
||||
UseWhen: []string{"在什么场景下应该选择这个工具"},
|
||||
AvoidWhen: []string{"什么场景不应该用,应该用什么替代"},
|
||||
Prerequisites: []string{"使用前提条件"},
|
||||
Tips: []string{"使用技巧"},
|
||||
Examples: []string{"dws dev app create --name Bot --dry-run"},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.InterfaceSpec(接口模式)
|
||||
|
||||
```go
|
||||
contract.InterfaceSpec{
|
||||
Mode: "composite", // local / mcp / composite
|
||||
Availability: "available", // available / unavailable
|
||||
Reason: "...", // composite/unavailable 时的原因
|
||||
Ref: &contract.InterfaceRefSpec{ // mcp 时的 ref
|
||||
ProductID: "...",
|
||||
RPCName: "...",
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
### contract.DryRunSpec(dry-run 能力)
|
||||
|
||||
```go
|
||||
contract.DryRunSpec{
|
||||
PreviewKind: "invocation", // invocation / request / plan
|
||||
RemoteReads: false, // dry-run 时是否发起远端读
|
||||
}
|
||||
```
|
||||
|
||||
## 执行体三选一
|
||||
|
||||
| 执行体 | 适用场景 | 框架做了什么 |
|
||||
|--------|----------|-------------|
|
||||
| **Invoke** | #830 过渡单步派发(生产仍用;目标 mcpbind) | 框架完成 required→constraint→validate→buildArgs→confirm,传入装配好的 toolArgs |
|
||||
| **Orchestrate** | #830 过渡多步编排(生产仍用;目标 Handler) | 框架完成 required→constraint→validate→confirm,传入 Ctx 自行组装调用 |
|
||||
| **RunE** | 逃生舱 | 框架仍执行 Safety 确认,具体业务执行完全自定义 |
|
||||
|
||||
## 设计不变量
|
||||
|
||||
1. **一个 corecmd.Spec = 一个叶子命令的全部事实**
|
||||
2. **声明面绝不调用后端**——command 是 dispatch-agnostic
|
||||
3. **执行面绝不发明 CLI 表面**——业务 flag 必须在 Flags 声明
|
||||
4. **构建时拦截 > 运行时报错**——声明错误 panic 在注册阶段
|
||||
5. **SafetySpec 是单一事实源**——Confirmation 驱动运行时,其余字段原样进入 Schema
|
||||
6. **声明即 review**——代码中的 Schema 经 code review 后直接投影,不依赖外部 hint 文件
|
||||
@@ -0,0 +1,286 @@
|
||||
# 命令框架架构
|
||||
|
||||
本文档描述 `internal/corecmd` 统一命令框架的当前架构,面向框架使用者和维护者。
|
||||
|
||||
## 概览
|
||||
|
||||
```
|
||||
用户输入 → cobra 命令树 → corecmd.New() → 运行时管线 → 后端派发
|
||||
```
|
||||
|
||||
命令框架将 CLI 命令的**声明**与**执行**分离:
|
||||
|
||||
- **声明面** — 数据字段描述命令是什么(flag、约束、SafetySpec、Contract 元数据)
|
||||
- **执行面** — 钩子函数描述命令做什么(校验、派发、编排)
|
||||
|
||||
框架负责:flag 注册、有效值回退链、required/约束校验、SafetySpec 确认、toolArgs 装配、Agent Runtime Schema 投影。
|
||||
|
||||
## 核心类型
|
||||
|
||||
### corecmd.Spec
|
||||
|
||||
统一的类型化命令规格,是框架的核心数据结构:
|
||||
|
||||
```go
|
||||
type Spec struct {
|
||||
// 声明面
|
||||
Use string
|
||||
Short string
|
||||
Long string
|
||||
Example string
|
||||
Flags []FlagSpec
|
||||
Constraints []Constraint
|
||||
Safety contract.SafetySpec // 运行时与 Schema 的单一安全来源
|
||||
ConfirmFirst bool // 确认门先于参数校验
|
||||
ConstParams map[string]any
|
||||
Contract ContractDecl // 叶子 Contract 声明(非 Catalog Schema)
|
||||
|
||||
// 执行面(恰好一个;Invoke/Orchestrate 为 #830 过渡派发 API,目标 mcpbind+Handler)
|
||||
Invoke func(c *Ctx, toolArgs map[string]any) error // 过渡:单步
|
||||
Orchestrate func(c *Ctx) error // 过渡:多步
|
||||
RunE func(cmd *cobra.Command, args []string) error // 逃生舱
|
||||
|
||||
// 钩子
|
||||
Validate func(cmd *cobra.Command, args []string) error
|
||||
PostMount func(cmd *cobra.Command)
|
||||
}
|
||||
```
|
||||
|
||||
### SafetySpec(单一安全来源)
|
||||
|
||||
`Spec.Safety` 使用 `corecmd/contract.SafetySpec`:
|
||||
|
||||
| 字段 | 职责 |
|
||||
|------|------|
|
||||
| `Effect` | 操作影响:read / write / destructive |
|
||||
| `Risk` | 风险等级:low / medium / high |
|
||||
| `Confirmation` | 是否需要用户确认:not_required / user_required |
|
||||
| `Idempotency` | 幂等性:idempotent / retryable / non_idempotent / unknown |
|
||||
|
||||
四个字段彼此独立。框架只读取 `Confirmation` 决定运行时确认,其余字段原样发布到 Schema,不从一个字段机械推导另一个。非空 SafetySpec 必须一次声明完整:
|
||||
|
||||
```go
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write",
|
||||
Risk: "high",
|
||||
Confirmation: "user_required",
|
||||
Idempotency: "unknown",
|
||||
},
|
||||
```
|
||||
|
||||
完全空值保留历史只读默认 `read/low/not_required/idempotent`;不存在 Risk/Safety 枚举或覆盖优先级链。
|
||||
|
||||
### FlagSpec
|
||||
|
||||
声明一个 flag 的注册方式、有效值回退链、到 toolArgs 的绑定:
|
||||
|
||||
```go
|
||||
type FlagSpec struct {
|
||||
Name string // flag 名(kebab-case)
|
||||
Usage string // --help 文案
|
||||
Kind FlagKind // String / Int / Bool / StringSlice
|
||||
Default string // 注册默认值
|
||||
Required bool // 框架校验非空
|
||||
Aliases []string // 隐藏别名
|
||||
EnvVar string // 环境变量回退
|
||||
Bind string // toolArgs 键名(空则用 Name)
|
||||
Transform func(string) (any, error) // 值转换
|
||||
// ...更多字段见源码
|
||||
}
|
||||
```
|
||||
|
||||
### Constraint
|
||||
|
||||
跨 flag 关系约束:
|
||||
|
||||
```go
|
||||
type Constraint struct {
|
||||
Kind ConstraintKind // at_least_one / exactly_one / mutually_exclusive
|
||||
Flags []string
|
||||
}
|
||||
```
|
||||
|
||||
## 有效值回退链
|
||||
|
||||
flag 解析按以下顺序取值(先命中先生效):
|
||||
|
||||
```
|
||||
显式主 flag (Changed) → 隐藏别名 (Changed) → 环境变量 → 注册默认值
|
||||
│
|
||||
ArgDefault ←──┘ (兜底)
|
||||
```
|
||||
|
||||
- KindBool:仅 Changed 时生效,不参与回退链
|
||||
- KindStringSlice:仅主 flag / alias Changed 时生效,元素恒 TrimSpace
|
||||
- KindInt:非零才入 toolArgs(putInt 语义)
|
||||
|
||||
## 构建时流程
|
||||
|
||||
`corecmd.New(spec)` 执行以下构建时检查(失败则 panic):
|
||||
|
||||
1. **validateDispatchDecl** — 恰好一个执行体(Invoke/Orchestrate/RunE)
|
||||
2. **validateSafetySpec** — 非空 SafetySpec 的四个独立字段必须完整
|
||||
3. **validateContractDecl** — Contract 声明完整性(Description、AgentSummary、UseWhen、AvoidWhen、Examples、Interface)
|
||||
4. **RegisterFlags** — flag + alias 注册到 cobra
|
||||
5. **ValidateConstraintDecls** — 约束引用的 flag 必须存在
|
||||
6. **embedContractIntoSchema** — 投影到 dws.schema.* annotations
|
||||
7. **AnnotateConstraints** — 约束渲染到 --help
|
||||
8. **PostMount** — 调用方的挂载收尾钩子
|
||||
|
||||
## 运行时流程
|
||||
|
||||
生成的 `RunE` 按以下顺序执行:
|
||||
|
||||
```
|
||||
[ConfirmFirst? → ConfirmSafety] ← 可选:先确认后校验
|
||||
│
|
||||
▼
|
||||
ValidateRequired ← 有效值回退链校验
|
||||
│
|
||||
▼
|
||||
ValidateConstraints ← 互斥/至少一个/恰好一个
|
||||
│
|
||||
▼
|
||||
Validate hook ← 条件式业务校验(可选)
|
||||
│
|
||||
▼
|
||||
BuildArgs ← flag → toolArgs 装配
|
||||
│
|
||||
▼
|
||||
ConstParams 合并
|
||||
│
|
||||
▼
|
||||
[!ConfirmFirst? → ConfirmSafety] ← 默认顺序:校验后确认
|
||||
│
|
||||
▼
|
||||
Invoke(ctx, toolArgs) ← #830 过渡:单步派发
|
||||
或 Orchestrate(ctx) ← #830 过渡:多步编排
|
||||
```
|
||||
|
||||
## 消费方式
|
||||
|
||||
### LeafSpec(MCP 直连叶子命令)
|
||||
|
||||
```go
|
||||
func newDevAppCreateCommand(runner executor.Runner) *cobra.Command {
|
||||
return NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建开放平台企业内部应用",
|
||||
Tool: devAppCreateTool,
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
ConfirmFirst: true,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "name", Usage: "应用名称 (必填)", Bind: "name",
|
||||
Trim: true, Required: true, RequiredHint: "--name 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建钉钉开放平台应用",
|
||||
UseWhen: []string{"需要新建企业内部应用"},
|
||||
AvoidWhen: []string{"应用已存在时用 update"},
|
||||
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
`NewLeafCommand` 经 `FromLeafSpec()` 归一为 `corecmd.Spec`,再交 `corecmd.New()` 构建。这是**完全托管模式**:声明 + 执行都归 command。
|
||||
|
||||
### 声明元数据模式(既有命令补 Contract)
|
||||
|
||||
执行体必须冻结时,用同一套 `LeafSpec` 词汇只声明元数据,写在命令字面量旁:
|
||||
|
||||
```go
|
||||
baseListCmd := &cobra.Command{
|
||||
Use: "list", Short: "获取 AI 表格列表",
|
||||
RunE: func(cmd *cobra.Command, args []string) error { /* 原执行体不动 */ },
|
||||
}
|
||||
DeclareLeafMetadata(baseListCmd, LeafSpec{
|
||||
Safety: aitableSafetyRead(),
|
||||
Contract: ContractDecl{
|
||||
Description: "列出最近访问的 AI 表格 Base。",
|
||||
Interface: aitableMCPInterface("list_bases"),
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出最近访问的 AI 表格 Base。",
|
||||
UseWhen: []string{"只需浏览最近打开过的 Base 时"},
|
||||
AvoidWhen: []string{"按名称查找优先 base search"},
|
||||
Examples: []string{"dws aitable base list"},
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
`DeclareLeafMetadata` 调用 `corecmd.AttachContract` 挂 Safety+Contract;不注册 flag、不接管参数投影。可选 `Validate` 与 `ConfirmSafety` 同挂在 **RunE 包装器**内(不是 PreRunE)。当 `Safety.Confirmation=user_required` 时,用**同一份** SafetySpec 包一层 `ConfirmSafety`,保证执行门禁与 Catalog 同源;无 Validate 时确认推迟到 gated `CallTool`,成功返回却未确认则 fail-closed。迁移态入口;新命令仍应走 `NewLeafCommand`。
|
||||
|
||||
### 三档路径(当前可接受)
|
||||
|
||||
| 档 | 入口 | 说明 |
|
||||
|---|---|---|
|
||||
| **Tier1** | `corecmd.New` / `NewLeafCommand` | 完全托管:声明 + 执行都归框架 |
|
||||
| **Tier2** | `DeclareLeafMetadata` | helpers 迁移态;**Shortcut 也可采用,可接受** |
|
||||
| **Tier3** | 裸 Cobra | 应逐步收;新增裸叶需补声明或精确排除 |
|
||||
|
||||
长期展望(非当前硬要求):更多 Shortcut 可收敛到 mcpbind / 减少仅为参数装配的 `Execute`。**不要**把「Shortcut 必须去掉 Execute / 必须 mcpbind」当作当前门禁;也不要否定 Shortcut + `DeclareLeafMetadata`。
|
||||
|
||||
### Shortcut(智能快捷方式,已接入 live mount)
|
||||
|
||||
```go
|
||||
func mount(s Shortcut) *cobra.Command {
|
||||
return corecmd.New(FromShortcut(s))
|
||||
}
|
||||
|
||||
spec := FromShortcut(Shortcut{
|
||||
Service: "chat",
|
||||
Command: "+demo",
|
||||
Risk: RiskHighWrite,
|
||||
Flags: []Flag{...},
|
||||
Execute: func(rt *RuntimeContext) error { ... },
|
||||
})
|
||||
```
|
||||
|
||||
Shortcut 当前仍保留自身的 `Risk`,adapter 只在边界将它展开成完整
|
||||
`contract.SafetySpec`;command/Leaf 不再保留该枚举。Shortcut 的 Cobra
|
||||
type/default/usage provenance 保持不变,command 统一补充 Required、Enum 和关系约束投影。
|
||||
需要补 Agent Schema 且执行体暂不迁入时,Shortcut 也可走 Tier2
|
||||
`DeclareLeafMetadata`(与 helpers 同一路径)。
|
||||
|
||||
## 文件结构
|
||||
|
||||
| 文件 / 包 | 职责 |
|
||||
|------|------|
|
||||
| `internal/corecmd/corecmd.go` | 核心类型 + `New` 构建器 + 运行时管线 |
|
||||
| `internal/corecmd/contract_decl.go` | ContractDecl 载荷类型 + 声明完整性守卫 |
|
||||
| `internal/corecmd/contract/` | 契约 DTO(`SafetySpec` / `ParamDecl` / `ProductDecl` / `ContractFinalPayload`);**无** Cobra-keyed Final store |
|
||||
| `internal/corecmd/runtimeannotate/` | `AnnotateRuntime*` 写注解(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/corecmd/contractfinal/` | ContractFinal Cobra store + `RegisterRuntimeContractFinal`(框架侧;`cli` 薄 re-export) |
|
||||
| `internal/cli/homology/` | flag/help/schema 同源门禁(`HOM-*`) |
|
||||
| `internal/helpers/leaf.go` | LeafSpec 门面:`NewLeafCommand`(完全托管)+ `DeclareLeafMetadata`(声明元数据) |
|
||||
| `internal/shortcut/adapter.go` | FromShortcut 完整映射与 Risk 兼容边界 |
|
||||
| `internal/shortcut/runner.go` | RuntimeContext;live mount 委托 `corecmd.New(FromShortcut(s))` |
|
||||
|
||||
## Schema 投影
|
||||
|
||||
声明即 review:代码中的 Contract 声明经过 code review 后直接投影为:
|
||||
|
||||
- **Agent Runtime Schema**(`dws.schema.*` Cobra annotations;经 `runtimeannotate` / ContractFinal 嵌入)
|
||||
- **运行时组装的 SchemaRegistry / Catalog ToolSpec wire**(`RegisterSchemaSourceRoot` → `ResolveSchemaBuild`;`dws schema` / `--all` / 完整 leaf 载荷)
|
||||
- **CommandMeta 投影**(装配 Once 同步缓存 `map[cli_path]CommandMeta`;`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety 稳态 O(1) 读缓存,与 SchemaRegistry 同源)
|
||||
- **Dry-run Capabilities**(声明自动索引为 reviewed 能力)
|
||||
|
||||
生产权威是 leaf `ContractFinal` / `ProductDecl`(经 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 装配进 Catalog);`InstallBuildTimeAgentMetadataJSON` 仅用于 `cmd_schema_catalog` 的 CI/local dump inject,不是生产交付路径。`schema_agent_metadata/` 与 `schema_hints/` 已退役。不再需要外部 hint 文件维护 selection/metadata/dry-run 信息。Catalog/meta-index 路径不得提交。
|
||||
|
||||
## 设计原则
|
||||
|
||||
1. **声明 vs 执行分离** — Flags/Constraints/Safety/Contract 是声明;Invoke/Validate/PostMount 是执行
|
||||
2. **单一数据源** — 一份声明驱动 --help、Schema、catalog、runtime 校验
|
||||
3. **安全字段不互推** — Confirmation 单独驱动确认,Effect/Risk/Idempotency 原样发布
|
||||
4. **构建时拦截 > 运行时报错** — 声明不完整在命令注册时 panic,不等到用户触发
|
||||
5. **边界兼容** — Shortcut 暂由 adapter 转换,Leaf 直接声明 SafetySpec
|
||||
@@ -0,0 +1,236 @@
|
||||
# 命令框架对比:DWS command vs lark-cli vs GWS
|
||||
|
||||
本文档对比 DWS(钉钉工作区 CLI)、lark-cli(飞书 CLI)和 GWS(Google Workspace CLI / gcloud)三套命令框架的设计差异。
|
||||
|
||||
## 总览对比
|
||||
|
||||
| 维度 | DWS (command) | lark-cli | GWS (gcloud) |
|
||||
|------|---------------|----------|--------------|
|
||||
| 语言 | Go | Go | Python (gcloud) / Go (部分) |
|
||||
| CLI 框架 | cobra | cobra | argparse + calliope |
|
||||
| 调用底座 | MCP JSON-RPC | Lark REST SDK (`CallAPITyped`) | Google API Client |
|
||||
| 命令层次 | 2 层:LeafSpec + Shortcut | 3 层:Shortcuts + API Commands + Raw API | 2 层:surface commands + raw |
|
||||
| Schema 来源 | 代码声明投影 | 代码声明 + 运行时 introspection | API Discovery 文档自动生成 |
|
||||
| Agent 适配 | 内建 (dws.schema.*) | 内建 (--print-schema) | 外挂 (MCP adapter) |
|
||||
|
||||
## 架构对比
|
||||
|
||||
### DWS command
|
||||
|
||||
```
|
||||
corecmd.Spec (声明) → corecmd.New() → cobra.Command
|
||||
│
|
||||
├── contract.SafetySpec (运行时 + Schema 单一安全来源)
|
||||
├── FlagSpec[] (参数 + 回退链 + 绑定)
|
||||
├── Constraint[] (互斥/至少一个)
|
||||
├── ContractDecl (Agent Selection/DryRun/Interface)
|
||||
│
|
||||
└── Invoke / Orchestrate / RunE (执行)
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- 声明与执行严格分离
|
||||
- SafetySpec 四个独立字段直接对齐 Agent Runtime Schema
|
||||
- 有效值回退链:flag → alias → env → default
|
||||
- 框架统一校验、装配、确认、投影
|
||||
- Schema 从代码声明直接投影,无外部 hint 文件
|
||||
|
||||
### lark-cli
|
||||
|
||||
```
|
||||
Shortcut (声明) → runner.Mount() → cobra.Command
|
||||
│
|
||||
├── Risk string (确认行为)
|
||||
├── Scopes / ConditionalScopes (OAuth 权限)
|
||||
├── Flag[] (参数 + Enum + Input sources)
|
||||
├── AuthTypes (user/bot)
|
||||
│
|
||||
├── DryRun hook → DryRunAPI
|
||||
├── Validate hook
|
||||
└── Execute hook → RuntimeContext → CallAPITyped
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- Execute 内直接调 REST API (`CallAPITyped`)
|
||||
- DryRun 是独立 hook(返回结构化 API 计划)
|
||||
- 内建 OAuth scope 声明与预检
|
||||
- `--print-schema --flag-name` 运行时 introspection
|
||||
- 无 Schema 投影层,Agent 通过 introspection 动态发现
|
||||
|
||||
### GWS (gcloud 风格)
|
||||
|
||||
```
|
||||
API Discovery → 代码生成 → surface command
|
||||
│
|
||||
├── arguments (从 JSON Schema 自动生成)
|
||||
├── request/response 映射
|
||||
└── 自定义 action hook (少量)
|
||||
```
|
||||
|
||||
**核心特点**:
|
||||
- Schema-first:从 API Discovery 文档自动生成命令
|
||||
- 参数直接映射 API 字段(flat schema)
|
||||
- 人工 surface command 是 thin wrapper
|
||||
- Agent 适配通过 MCP 外部 adapter
|
||||
|
||||
## 核心设计差异
|
||||
|
||||
### 1. 声明粒度
|
||||
|
||||
| 能力 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 参数别名 + 环境变量回退 | ✅ FlagSpec.Aliases + EnvVar | ❌ 无 | ❌ 无 |
|
||||
| 声明式约束 (互斥/至少一个) | ✅ Constraint[] | ❌ 只有 Validate hook | ✅ argparse group |
|
||||
| 安全契约 | ✅ SafetySpec(effect/risk/confirmation/idempotency) | Risk | 无 |
|
||||
| Schema 投影 (Agent metadata) | ✅ ContractDecl 内建 | ⚠️ 运行时 introspection | ❌ 外挂 |
|
||||
| 参数绑定 (flag name → API key) | ✅ FlagSpec.Bind | ❌ 手写 | ✅ 自动映射 |
|
||||
| ConstParams (固定载荷) | ✅ | ❌ 手写在 Execute | ✅ 隐式 |
|
||||
| 确认门顺序可配 (ConfirmFirst) | ✅ | ❌ 固定顺序 | ❌ 无确认机制 |
|
||||
|
||||
### 2. 执行模型
|
||||
|
||||
| 维度 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 参数装配 | 框架自动 (BuildArgs) | 手写 (`runtime.Str()/Bool()`) | 自动映射 |
|
||||
| 派发方式 | Invoke(ctx, toolArgs) | Execute(ctx, runtime) | 自动调用 |
|
||||
| 多步编排 | Orchestrate(ctx) | Execute 内链式 CallAPITyped | 不支持 |
|
||||
| DryRun | 框架统一 (--dry-run flag) | 独立 DryRun hook 返回 API 计划 | 部分命令支持 |
|
||||
| 错误分类 | apperrors 类型化 | errs.Problem 类型化 | HTTP status 映射 |
|
||||
|
||||
### 3. Agent 适配
|
||||
|
||||
| 维度 | DWS command | lark-cli | GWS |
|
||||
|------|-------------|----------|-----|
|
||||
| 工具发现 | `dws schema --all` (静态 catalog) | `--print-schema` (运行时) | API Discovery |
|
||||
| 选择指引 | contract.SelectionSpec (UseWhen/AvoidWhen) | Description + Tips | 无 |
|
||||
| 安全声明 | contract.SafetySpec 直接声明 | Risk string | 无 |
|
||||
| dry-run 能力声明 | contract.DryRunSpec (reviewed) | DryRun hook 存在性 | 无 |
|
||||
| 接口模式 | contract.InterfaceSpec (local/mcp/composite) | 隐式 (全部 REST) | 隐式 (全部 REST) |
|
||||
|
||||
### 4. Schema 生命周期
|
||||
|
||||
```
|
||||
DWS: 代码声明 → code review → cobra annotation → catalog/metadata JSON
|
||||
(单一数据源,构建时验证完整性)
|
||||
|
||||
lark-cli: 代码声明 → 运行时 introspection → Agent 动态发现
|
||||
(无离线 catalog,Agent 必须执行命令才能发现)
|
||||
|
||||
GWS: API Discovery JSON → 代码生成 → surface command
|
||||
(Schema-first,但命令行体验受限于 API 形状)
|
||||
```
|
||||
|
||||
## 设计哲学对比
|
||||
|
||||
### DWS command 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| 框架装配参数 | 消除 N 个命令各写一份 toolArgs 装配 | lark-cli 每个 Execute 手动取 flag 值 |
|
||||
| SafetySpec 单一来源 | confirmation 驱动运行时,其余字段原样发布且互不推导 | lark-cli 只有 Risk 一个维度 |
|
||||
| 声明式约束 | 构建时校验合法性 + 投影到 Schema + 渲染帮助 | lark-cli 约束隐藏在 Validate 逻辑里 |
|
||||
| Schema 构建时投影 | 离线 catalog 支持 Agent 批量发现 | lark-cli 需要逐个命令 introspection |
|
||||
| 有效值回退链 | flag → alias → env 统一语义 | lark-cli 别名是独立 Flag 手动关联 |
|
||||
| ConfirmFirst | 精确建模遗留语义 | lark-cli 确认始终在 Execute 内 |
|
||||
|
||||
### lark-cli 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| 直连 REST API | 精确控制请求/响应,可处理分页/重试 | DWS 通过 MCP 间接调用 |
|
||||
| DryRun 返回 API 计划 | Agent 可预览将要发出的真实 HTTP 请求 | DWS dry-run 只展示参数 |
|
||||
| OAuth scope 声明 | 框架预检权限,失败提前 | DWS 依赖 MCP 层鉴权 |
|
||||
| `--print-schema` introspection | 运行时发现,无需维护离线 catalog | DWS 需要 re-generate |
|
||||
| Input sources (file/@path/stdin) | 丰富的输入方式声明 | DWS 无此抽象 |
|
||||
| PrintFlagSchema | 单 flag 级别的 JSON Schema 暴露 | DWS 只在 catalog 级别 |
|
||||
|
||||
### GWS (gcloud) 的选择
|
||||
|
||||
| 选择 | 理由 | 对比 |
|
||||
|------|------|------|
|
||||
| API Discovery 驱动 | 一份 Schema 生成所有:SDK/CLI/文档 | DWS/lark 手写 |
|
||||
| Flat parameter 映射 | API 字段 = CLI flag,零转换 | DWS 需要 Bind 映射 |
|
||||
| 无 shortcut 层 | API 粒度即用户粒度 | DWS/lark 有精选层 |
|
||||
|
||||
## 代码量对比
|
||||
|
||||
| 框架 | 核心框架代码 | 单命令声明开销 | 备注 |
|
||||
|------|-------------|---------------|------|
|
||||
| DWS command | ~1400 行 (command.go + contract_decl.go) | ~20-30 行 (纯声明) | 框架重、单命令轻 |
|
||||
| lark-cli | ~800 行 (runner.go + types.go + common.go) | ~50-150 行 (声明 + Execute 逻辑) | 框架轻、单命令重 |
|
||||
| GWS gcloud | ~5000+ 行 (calliope 框架) | ~10 行 (多数自动生成) | 框架最重、单命令最轻 |
|
||||
|
||||
## DWS 命令声明示例 vs lark-cli
|
||||
|
||||
### DWS (command / LeafSpec)
|
||||
|
||||
```go
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "create",
|
||||
Short: "创建应用",
|
||||
Tool: "create_dev_app",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
ConfirmFirst: true,
|
||||
Flags: []LeafFlag{
|
||||
{Name: "name", Usage: "应用名称", Bind: "name",
|
||||
Trim: true, Required: true, RequiredHint: "--name 为必填"},
|
||||
},
|
||||
Contract: ContractDecl{
|
||||
Description: "创建开放平台企业内部应用",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "invocation"},
|
||||
Interface: &contract.InterfaceSpec{Mode: "composite", Availability: "available", Reason: "create then configure"},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "创建钉钉开放平台应用",
|
||||
UseWhen: []string{"需要新建企业内部应用"},
|
||||
AvoidWhen: []string{"应用已存在时用 update"},
|
||||
Examples: []string{`dws dev app create --name "Bot" --dry-run`},
|
||||
},
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
})
|
||||
```
|
||||
|
||||
### lark-cli (Shortcut)
|
||||
|
||||
```go
|
||||
var CalendarCreate = common.Shortcut{
|
||||
Service: "calendar",
|
||||
Command: "+create",
|
||||
Description: "Create a new calendar event",
|
||||
Risk: "write",
|
||||
Scopes: []string{"calendar:calendar"},
|
||||
Flags: []common.Flag{
|
||||
{Name: "summary", Desc: "Event title", Required: true},
|
||||
{Name: "start", Desc: "Start time (RFC3339)", Required: true},
|
||||
{Name: "end", Desc: "End time (RFC3339)", Required: true},
|
||||
{Name: "attendees", Type: "string_slice", Desc: "Attendee emails"},
|
||||
},
|
||||
DryRun: func(ctx context.Context, rt *common.RuntimeContext) *common.DryRunAPI {
|
||||
return &common.DryRunAPI{
|
||||
Method: "POST",
|
||||
Path: "/open-apis/calendar/v4/calendars/{id}/events",
|
||||
Body: buildEventBody(rt),
|
||||
}
|
||||
},
|
||||
Execute: func(ctx context.Context, rt *common.RuntimeContext) error {
|
||||
body := buildEventBody(rt)
|
||||
data, err := rt.CallAPITyped("POST",
|
||||
"/open-apis/calendar/v4/calendars/{id}/events", nil, body)
|
||||
if err != nil { return err }
|
||||
return rt.Output(data)
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## 适用场景总结
|
||||
|
||||
| 场景 | 最适合 | 原因 |
|
||||
|------|--------|------|
|
||||
| MCP 后端 + Agent Schema 投影 | **DWS command** | 内建 Schema 声明、SafetySpec 契约、离线 catalog |
|
||||
| REST API 直连 + OAuth scope 管理 | **lark-cli** | CallAPITyped + scope 预检 + DryRun API 计划 |
|
||||
| API-first 大规模 surface 生成 | **GWS gcloud** | Discovery 驱动,一份 Schema 生成一切 |
|
||||
| 多步编排 (跨服务链式调用) | **lark-cli** / DWS Orchestrate | lark 的 CallAPITyped 链式 + DWS 的 Orchestrate |
|
||||
| 遗留系统迁移 (保持行为等价) | **DWS command** | ConfirmFirst + 回退链 + catalog 漂移门禁 |
|
||||
@@ -40,7 +40,7 @@ Every command inherits these flags (documented here once, not repeated per comma
|
||||
- [`dws doc` — DingTalk Doc](#dws-doc) · 21 commands
|
||||
- [`dws drive` — DingTalk Drive](#dws-drive) · 6 commands
|
||||
- [`dws minutes` — AI Minutes](#dws-minutes) · 19 commands
|
||||
- [`dws oa` — OA Approval](#dws-oa) · 9 commands
|
||||
- [`dws oa` — OA Approval](#dws-oa) · 12 commands
|
||||
- [`dws report` — Reports](#dws-report) · 7 commands
|
||||
- [`dws todo` — Todo Tasks](#dws-todo) · 6 commands
|
||||
|
||||
@@ -277,14 +277,17 @@ _AI meeting notes: listing, summary, todos, transcription, recording control, mi
|
||||
|
||||
## `dws oa` — OA Approval
|
||||
|
||||
_OA approval workflows: list, approve, reject, revoke, records._
|
||||
_OA approval workflows: inspect forms, forecast routes, create instances, approve, reject, revoke, and audit records._
|
||||
|
||||
**9 commands**
|
||||
**12 commands**
|
||||
|
||||
| Command | Description | When to use |
|
||||
|---|---|---|
|
||||
| `dws oa approval approve` | Approve a pending approval process instance (task) as the current user. | When the agent acts on a pending approval the user has delegated it to handle. |
|
||||
| `dws oa approval create-instance` | Create a real approval process instance from validated form values or a complete request payload. | After the agent has inspected the form Schema, forecast the route, resolved any selectable approvers, and obtained explicit user confirmation. |
|
||||
| `dws oa approval detail` | Retrieve full details of an approval process instance, including form fields, attachments, and state. | When the agent needs to read the content of an approval ticket before deciding on it or summarizing it. |
|
||||
| `dws oa approval form-schema` | Retrieve the form Schema for an approval template by processCode. | Before collecting or validating values for a new approval instance. |
|
||||
| `dws oa approval forecast-process` | Forecast the approval route for a template and its proposed form values. | Before creating an instance, especially when the route contains user-selectable approver or notifier nodes. |
|
||||
| `dws oa approval list-forms` | List approval process templates (forms) the current user is allowed to initiate. | When the agent needs to pick the right approval form before submitting a new request. |
|
||||
| `dws oa approval list-initiated` | List approval process instances the current user has initiated. | When the agent reviews the status of approvals the user submitted. |
|
||||
| `dws oa approval list-pending` | List approval process instances currently awaiting action from the current user. | When the agent surfaces "needs your approval" items in the user's inbox. |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# dws dev 命令集 · Agent 人肉手工评测集(10 条复合用例)
|
||||
|
||||
> 性质:**人肉手工评测集**——由测评人逐条手工跑、肉眼核对、人工判分,不是自动化脚本。
|
||||
> 用途:评测 agent(加载 `dingtalk-dev` 技能后)能否正确处理开放平台 dev 任务。
|
||||
> 用途:评测 agent(加载 `dingtalk-misc` 的 `references/devapp.md` 后)能否正确处理开放平台 dev 任务。
|
||||
> 特点:10 条**复合用例**,每条串多个子任务,一条覆盖一类完整场景;10 条合起来覆盖全部 34 个子命令 + 8 类横切行为。
|
||||
> 约定:所有命令应带 `--format json`;写操作应先 `--dry-run` 预览、用户确认后再 `--yes`;应用定位只用 `--unified-app-id`。
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 一键安装
|
||||
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-dev` skill,**只需要 curl + tar,不需要 git / go / make**。
|
||||
`dws dev` 能力已经合入主干并随正式版发布。专用安装脚本会下载预编译二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),**只需要 curl + tar,不需要 git / go / make**。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
@@ -24,7 +24,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
|
||||
1. 从 `DingTalk-Real-AI/dingtalk-workspace-cli` 的最新 Release 下载对应平台的预编译二进制。
|
||||
2. 安装 `dws` 到默认目录 `~/.local/bin`。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-dev` skill 到本机已检测到的 Agent 目录。
|
||||
3. 从 Release 的 skills 包里安装 `dingtalk-misc` skill 到本机已检测到的 Agent 目录。
|
||||
|
||||
支持这些环境变量(全部可选):
|
||||
|
||||
@@ -33,7 +33,7 @@ irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/ma
|
||||
| `DEVAPP_REPO` | 覆盖发布仓库,默认 `DingTalk-Real-AI/dingtalk-workspace-cli` |
|
||||
| `DEVAPP_VERSION` | 钉某个 release tag,默认取最新 release |
|
||||
| `DWS_INSTALL_DIR` | 二进制安装目录,默认 `~/.local/bin` |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-dev` skill 安装 |
|
||||
| `DWS_NO_SKILLS` | 设为 `1` 跳过 `dingtalk-misc` skill 安装 |
|
||||
|
||||
> `dws dev` 已在正式版里,所以你也可以直接用标准安装脚本 `install.sh`,二者都会带上 `dws dev`。
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
# 5分钟搭好团队知识库:DWS 让 IT 服务台自己开口答疑,你值得拥有
|
||||
|
||||
> 💡 **省流版摘要:**
|
||||
> 别再用鼠标一个个建文档了,真的没必要!本文带你用 DWS(钉钉命令行工具),5 分钟搭好一个团队知识库:建空间、搭目录、批量导入 FAQ、配权限,全程命令行一步到位。最后再花 30 秒把它挂到钉钉机器人上——同事在群里 @ 一下,知识库自己开口答疑。知识管理员从"搬运工"升级为"甩手掌柜",就是这么简单。
|
||||
>
|
||||
> (温馨提示:本文内容可以直接丢给智能体,让智能体一次性逐步安装和使用)
|
||||
|
||||
## 一、痛点吐槽:管知识库有多累?
|
||||
|
||||
身为企业 IT / 知识管理员,你肯定经历过这些"九九八十一难"……
|
||||
|
||||
- **点击马拉松**:建空间点 5 下、建文件夹点 3 下、建一篇文档再点 4 下。一个季度下来,鼠标点击次数比写的字还多。
|
||||
- **搬运工噩梦**:几百篇历史 FAQ 散落在本地 Word / Markdown 里,要搬进钉钉知识库?复制粘贴到天荒地老,格式还经常翻车。
|
||||
- **权限苦差**:新同事入职要加权限、转岗要改角色、离职要移除。逐个空间点进去操作,漏一个就是安全隐患。
|
||||
- **知识沉睡**:库是建好了,可同事还是习惯私聊问你"VPN 又连不上了怎么办"。知识库躺着吃灰,你继续当人肉客服。😭
|
||||
|
||||
今天,DWS(DingTalk Workspace CLI)闪亮登场!🌟
|
||||
|
||||
你不需要写一行代码,只要在终端敲几行命令,知识库的"建、搬、管、用"全链路一次搞定。更香的是:搭好的知识库可以直接挂到钉钉机器人上,让知识自己开口答疑。
|
||||
|
||||
## 二、DWS 是个啥?知识库的"遥控指挥中心"
|
||||
|
||||
DWS 是钉钉能力的原子化封装,把复杂的 OpenAPI 打包成简单指令。管知识库这件事,主要靠它的"三驾马车":
|
||||
|
||||
| 命令族 | 能干什么 |
|
||||
|---|---|
|
||||
| 🗂️ `dws wiki` | 知识库空间、目录节点、成员权限的全生命周期管理 |
|
||||
| 📄 `dws doc` | 文档内容读写、本地文件批量导入、模板套用 |
|
||||
| 📁 `dws drive` | 钉盘文件上传下载、全局搜索、归档备份 |
|
||||
|
||||
你可以把它想象成知识库的"遥控指挥中心" 🎮——既能你手动按(终端敲命令,比点界面快 10 倍),也能让 AI 帮你按(Claude Code、Qoder 等智能体直接听懂并调用)。
|
||||
|
||||
**适合谁用:**
|
||||
|
||||
- **企业 IT / 知识管理员**:批量建库、批量导入、批量管权限,脚本化解放双手
|
||||
- **开发者 / AI 玩家**:把知识库挂到机器人上,打造 24 小时答疑小能手
|
||||
- **重度钉钉用户 / 效率党**:一条命令搜全库,比在界面里翻目录快得多
|
||||
|
||||
## 三、搭好的知识库能帮你做什么?
|
||||
|
||||
| 场景 | 玩法 |
|
||||
|---|---|
|
||||
| 🛟 IT 服务台 FAQ 库 | VPN、邮箱、打印机常见问题集中沉淀,机器人自动答疑 |
|
||||
| 📜 制度流程库 | 报销、请假、采购制度批量导入,全文秒搜 |
|
||||
| 🎓 新人上岗手册 | 按部门建目录,入职即授权限,自助通关 |
|
||||
| 🤖 知识库 + 机器人 | `--knowledge-source wiki:<spaceId>` 一挂,群里 @ 它就答 |
|
||||
|
||||
所想即所得,拒绝画饼,直接上菜!🍽️
|
||||
|
||||
## 四、5分钟倒计时,搭好你的团队知识库
|
||||
|
||||
> 以下命令全部经过真实环境跑通验证,放心照抄。
|
||||
|
||||
### 0. 安装并登录 DWS(约 1 分钟)
|
||||
|
||||
把以下指令复制给你的智能体(Claude Code、Qoder、Codex 等)执行,或手动在终端跑:
|
||||
|
||||
macOS / Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.sh | sh
|
||||
```
|
||||
|
||||
Windows(PowerShell):
|
||||
|
||||
```powershell
|
||||
irm https://raw.githubusercontent.com/DingTalk-Real-AI/dingtalk-workspace-cli/main/scripts/install.ps1 | iex
|
||||
```
|
||||
|
||||
登录(提示授权请扫码):
|
||||
|
||||
```bash
|
||||
dws auth login
|
||||
```
|
||||
|
||||
【截图位:dws auth status 显示 token_valid: true】
|
||||
|
||||
### 1. 建一个知识库空间(10 秒)
|
||||
|
||||
```bash
|
||||
dws wiki space create --name "IT服务台知识库" --desc "IT 常见问题与制度流程"
|
||||
```
|
||||
|
||||
返回里的 `workspaceId` 就是空间的身份证号,后面每步都要用它。
|
||||
|
||||
【截图位:返回 workspaceId 与 spaceUrl】
|
||||
|
||||
### 2. 搭目录结构(20 秒)
|
||||
|
||||
知识库的结构 = 文件夹节点 + 文档节点。先建分类文件夹:
|
||||
|
||||
```bash
|
||||
dws wiki node create --workspace <workspaceId> --name "常见问题FAQ" --type folder
|
||||
dws wiki node create --workspace <workspaceId> --name "制度流程" --type folder
|
||||
```
|
||||
|
||||
在文件夹下建一篇空文档(不加 `--folder` 就建在根目录):
|
||||
|
||||
```bash
|
||||
dws wiki node create --workspace <workspaceId> --name "VPN连接失败排查指南" --folder <文件夹nodeId>
|
||||
```
|
||||
|
||||
### 3. 批量导入历史文档(1 分钟,重头戏!)
|
||||
|
||||
几百篇本地 FAQ 不用复制粘贴,`doc import` 直接整批灌进知识库,Word、Excel、Markdown、txt 通吃:
|
||||
|
||||
```bash
|
||||
# 单篇导入到指定文件夹
|
||||
dws doc import --file ./vpn-faq.md --workspace <workspaceId> --folder <文件夹nodeId> --name "VPN连接失败排查指南"
|
||||
|
||||
# 批量导入整个目录(bash 一把梭)
|
||||
for f in ./faq/*.md; do
|
||||
dws doc import --file "$f" --workspace <workspaceId> --folder <文件夹nodeId>
|
||||
done
|
||||
```
|
||||
|
||||
已有在线文档想补内容?Markdown 直接写入:
|
||||
|
||||
```bash
|
||||
dws doc update --node <文档nodeId> --content-file ./补充内容.md --mode append
|
||||
```
|
||||
|
||||
【截图位:终端批量导入的滚动输出 + 知识库里齐刷刷的文档列表】
|
||||
|
||||
### 4. 配权限:把人拉进来(30 秒)
|
||||
|
||||
```bash
|
||||
# 先用通讯录查到同事的 userId
|
||||
dws contact user search --query "张三"
|
||||
|
||||
# 加为编辑者(--users 支持逗号分隔批量加)
|
||||
dws wiki member add --workspace <workspaceId> --users <userId1>,<userId2> --role EDITOR
|
||||
|
||||
# 随时盘点成员
|
||||
dws wiki member list --workspace <workspaceId>
|
||||
```
|
||||
|
||||
### 5. 验收:搜一下,秒级命中(10 秒)
|
||||
|
||||
```bash
|
||||
# 库内全文搜索
|
||||
dws wiki node search --workspace <workspaceId> --query "VPN"
|
||||
|
||||
# 全局搜知识库空间
|
||||
dws wiki space search --query "IT服务台"
|
||||
```
|
||||
|
||||
【截图位:搜索结果命中文档标题】
|
||||
|
||||
### 6. 封神一步:挂到机器人上,知识自己开口答疑(30 秒)
|
||||
|
||||
如果你已经按《5分钟抱走你的嘴替机器人》建好了钉钉机器人,只需加一个参数:
|
||||
|
||||
```bash
|
||||
dws dev connect --channel claudecode \
|
||||
--robot-client-id <你的机器人ID> --robot-client-secret <你的机器人密钥> \
|
||||
--knowledge-source wiki:<workspaceId>
|
||||
```
|
||||
|
||||
机器人会自动从知识库拉取知识并缓存。同事在群里 @ 它问"VPN 连不上怎么办",它直接引用你刚导入的排查指南回答——你,终于不用当复读机了。😎
|
||||
|
||||
## 五、进阶使用技巧
|
||||
|
||||
### 知识库管理速查表
|
||||
|
||||
| 操作 | 命令 |
|
||||
|---|---|
|
||||
| 列出我的个人空间 | `dws wiki space list --type myWikiSpace` |
|
||||
| 列出组织知识库 | `dws wiki space list --type orgWikiSpace` |
|
||||
| 浏览库内节点树 | `dws wiki node list --workspace <ID> [--folder <nodeId>]` |
|
||||
| 移动 / 复制节点 | `dws wiki node move` / `dws wiki node copy` |
|
||||
| 改成员角色 | `dws wiki member update --users <UID> --role VIEWER` |
|
||||
| 移除成员 | `dws wiki member remove --users <UID>` |
|
||||
| 删除整个空间 | `dws wiki space delete --workspace <ID>`(进回收站,可恢复) |
|
||||
|
||||
### 老手避坑指南 ⛳
|
||||
|
||||
- 建在线表格用 `--type axls`,**`asheet` 服务端不支持**,别踩坑。
|
||||
- `member list` 只返回姓名和角色、**不返回 userId**;要串联 `update` / `remove`,先用 `dws contact user search --query "<姓名>"` 反查。
|
||||
- 搜索关键词的 flag 是 `--query`,`--keyword` 是遗留别名,新脚本请用 `--query`。
|
||||
- 所有命令加 `--format json`,配合 `--jq` 过滤字段,写脚本时稳得一批。
|
||||
- 破坏性操作(删空间、覆盖写文档)前加 `--dry-run` 先预览,确认无误再执行。
|
||||
|
||||
### 让机器人答得更好
|
||||
|
||||
| 开关 | 作用 |
|
||||
|---|---|
|
||||
| `--knowledge-source wiki:<spaceId>` | 从钉钉知识库拉知识作为答疑来源(本文主角) |
|
||||
| `--knowledge-dir <目录>` | 挂本地 .md/.txt 知识目录,可与上面并存 |
|
||||
| `--allowed-groups / --allowed-users` | 白名单,只让指定群或人触发 |
|
||||
| `--daemon` | 后台常驻,关掉终端也不断线 |
|
||||
|
||||
## 六、更多 DWS 官方信息
|
||||
|
||||
- 钉钉 CLI 官网:https://open.dingtalk.com/dingtalk-cli
|
||||
- 开源仓库:https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli
|
||||
- 上一篇姊妹篇:《5分钟抱走你的嘴替机器人:启动钉钉DWS,你值得拥有》
|
||||
|
||||
## 七、欢迎加入交流群
|
||||
|
||||
【截图位:DWS 交流群二维码】
|
||||
|
||||
遇到问题来群里喊一声,官方同学在线答疑。下一篇想看什么?批量备份知识库?给知识库做权限审计?留言区点菜!🍻
|
||||
@@ -4,7 +4,7 @@ Defines the stable `dws event consume` subprocess contract so an
|
||||
orchestrator can determine when the consumer is ready, stop it cleanly,
|
||||
and machine-read why it exited.
|
||||
|
||||
Scope of this branch: the four **contract** items below. Reconnect
|
||||
Scope of this branch: the five **contract** items below. Reconnect
|
||||
resilience (keeping the stream alive across a transient upstream drop) is
|
||||
tracked separately and intentionally out of scope here.
|
||||
|
||||
@@ -92,6 +92,73 @@ Ownership-based cleanup:
|
||||
- T4c (control): `kill -9` leaves subscribe_id lingering (documented risk;
|
||||
we only guarantee SIGTERM is clean, we do not fix kill -9 itself).
|
||||
|
||||
### 5. Subscription-create retry orchestration and local guard
|
||||
|
||||
This policy covers all 16 public personal-event keys and every logical
|
||||
subscription in a multi-event command. It applies only before the ready
|
||||
marker; reconnecting an established Stream remains a separate mechanism.
|
||||
|
||||
- The `0/2/1` limits below are an **Agent/host orchestration contract**, not
|
||||
a CLI-enforced persisted total-attempt cap. Each `dws event consume`
|
||||
process sends at most one subscription-create HTTP request for a logical
|
||||
subscription and performs no in-process automatic retry. The CLI persists
|
||||
only the `in_flight`, `cooldown`, and `terminal_hold` guard states; it does
|
||||
not persist or enforce the Agent/host attempt count across invocations.
|
||||
- ID resolution, `event consume`, and later `event status/stop` must use the
|
||||
same `--profile`. A user or conversation ID resolved under another profile
|
||||
must not be reused for the current subscription.
|
||||
- A logical subscription is keyed by the current profile/identity, event key,
|
||||
rule type, target, and filters. A new `subscribe_id`, `trace_id`, or process
|
||||
does not create a new logical operation or reset the Agent/host budget.
|
||||
- For the Agent/host, `retryable=false` means
|
||||
`max_additional_attempts=0`.
|
||||
- For the Agent/host, `retryable=true` means
|
||||
`max_additional_attempts=2`. It must honor `retry_after_seconds` or
|
||||
`next_retry_at` when present and must not retry early.
|
||||
- For the Agent/host, an omitted retryable value
|
||||
(`retryable=unknown`) means `max_additional_attempts=1`; a second unknown
|
||||
failure stops the operation.
|
||||
- `in_flight` means the original logical request is still running.
|
||||
`cooldown` and `terminal_hold` mean a guard is already delaying or blocking
|
||||
it. These states must not recursively launch `event consume`, start a
|
||||
parallel equivalent subscription, or bypass the guard with a new subId or
|
||||
trace. The caller waits for the original request/guard or stops, while the
|
||||
Agent/host keeps its own orchestration count.
|
||||
- A multi-event command remains one original operation. A caller must not
|
||||
split out a failed event, reorder events, or restart the command to bypass
|
||||
a budget. Existing startup rollback cleans subscriptions created before a
|
||||
later item fails.
|
||||
|
||||
#### Local guard state operations
|
||||
|
||||
- The default open-edition state file is
|
||||
`~/.dws/events/open/personal_stream/<identity_hash>/personal_subscription_attempts.json`.
|
||||
The config root follows `DWS_CONFIG_DIR` when set, and another edition uses
|
||||
that edition's directory instead of `open`.
|
||||
- The identity directory is mode `0700`; both
|
||||
`personal_subscription_attempts.json` and
|
||||
`personal_subscription_attempts.lock` are mode `0600`.
|
||||
- A failure streak resets after 24h without another failure. A
|
||||
`terminal_hold` lasts 1h. Prefer waiting until the reported
|
||||
`next_retry_at`; do not clear the file as a normal retry mechanism.
|
||||
- For emergency recovery, first ensure that no subscription-create process is
|
||||
running for that identity. Delete only
|
||||
`personal_subscription_attempts.json`, never the lock file. This clears
|
||||
every protection record for that identity, not just one event.
|
||||
|
||||
**Verification**
|
||||
- T5a (policy): skill/docs tests pin the Agent/host 0/2/1 orchestration
|
||||
contract and explicitly reject describing it as a CLI-persisted hard cap.
|
||||
- T5b (CLI): one process issues at most one create request per logical
|
||||
subscription; a changed subId/trace or process restart does not bypass the
|
||||
persisted fingerprint guard.
|
||||
- T5c: `in_flight`/`cooldown` does not recursively issue another create.
|
||||
- T5d: multi-event startup cannot be split or reordered to bypass the guard,
|
||||
and a partial startup still rolls back earlier subscriptions.
|
||||
- T5e: state-store tests cover `0700`/`0600` permissions, 24h reset, 1h
|
||||
`terminal_hold`, and identity-scoped cleanup; skill/docs tests pin the
|
||||
operational recovery instructions.
|
||||
|
||||
## Out of scope (next branch)
|
||||
|
||||
**Reconnect resilience** — today `personal source` retries only
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# flag / help / schema 同源
|
||||
|
||||
- **状态**:已决策(路径 A + Contract 嵌入 Schema)
|
||||
- **相关**:[`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md)、[`schema-dynamic-endpoint-design.md`](schema-dynamic-endpoint-design.md)
|
||||
- **实现门面**:`LeafSpec` → `corecmd.Spec` → `corecmd.New`
|
||||
|
||||
## 1. 决策
|
||||
|
||||
采用 **路径 A:Contract / LeafSpec 为 CLI 表面权威**,并且 **Contract 必须嵌入进 Schema**。
|
||||
|
||||
「同源」的含义是:同一份 Contract(今日经 LeafSpec / `corecmd.Spec` 表达)同时决定——且门禁能证明——
|
||||
|
||||
1. cobra 实际注册的 flags / required / defaults;
|
||||
2. `--help` 的 Flags 与「参数约束」段;
|
||||
3. 运行时**组装后的** Schema 交付中的 parameters、关系约束和 SafetySpec:
|
||||
- **SchemaRegistry / Catalog ToolSpec wire** 供 `dws schema` / `--all` / 完整 leaf 载荷(lazy;首次 `dws schema` 触发完整装配)。
|
||||
- **`ResolveMeta` / `SafetyForCLIPath` / leaf `--help` Safety** 与装配同源:`deliverySchemaCatalog` sync.Once 装配后同步物化 `map[cli_path]CommandMeta`;稳态为 O(1) map lookup,不为 Meta 单独重做全量 ToolSpec/wire 投影。
|
||||
Agent metadata 在组装期间经内存 inject,不落盘、不 embed;`schema_agent_metadata/` 已退役,若存在则 policy 失败。
|
||||
|
||||
嵌入机制(已落地):
|
||||
|
||||
```text
|
||||
corecmd.Spec
|
||||
→ RegisterFlags + embedContractIntoSchema
|
||||
→ cobra annotations:
|
||||
dws.schema.contract=command
|
||||
dws.schema.property / type / required (per flag)
|
||||
dws.schema.constraints
|
||||
→ RegisterRuntimeContractFinal(SafetySpec + ContractDecl)
|
||||
→ Schema 组装透传 Contract Final
|
||||
(组装时内存 inject Agent metadata)
|
||||
→ RegisterSchemaSourceRoot → ResolveSchemaBuild
|
||||
→ SchemaRegistry (+ Meta cache map) / dws schema wire projection
|
||||
```
|
||||
|
||||
command/Leaf 不再写 `dws.schema.risk`;SafetySpec 走类型化 Final 载荷,不使用字符串枚举注解。
|
||||
|
||||
### 1.1 硬规则:声明 = 最终数据源(Schema 透传)
|
||||
|
||||
受管命令进入 Schema 的叶子数据由 **Contract 声明**定义最终值;框架(`corecmd.New`)做**类型转换**并注册,Schema 组装**透传**,不得:
|
||||
|
||||
- 把声明序列化成 JSON 注解再解析;
|
||||
- 在声明体系里再挂「评审字段」并行权威;
|
||||
- 用 hints/registry 盖写已声明字段。
|
||||
|
||||
**declare-vs-delivery 例外(Title / Description)**:构造期 `ContractDecl.Description` **必填**(声明证据),但这不是「declare = wire 最终值」。Catalog **交付**时:
|
||||
|
||||
- **description**:有 Cobra Long → 交付 Long(provenance `cobra_help` / `cobra_help_preferred`);无 Long → 交付声明(`contract_final`)。**Short 不进入 description**。
|
||||
- **title**:声明 `ContractDecl.Title` 优先,否则 Cobra Short,再 MCP;组装 stamp 真实 winner。
|
||||
|
||||
这是一条权威链上的显式交付偏好,不是双权威(见 RFC §5.0.4)。
|
||||
|
||||
迁移期未迁完的叶子可暂走旧组装路径;**新声明面不含 review_reason / reviewed 字段**。写命令未设 `Safety` 时,过渡期仍可用 `runtime_gate` annotate(`HOM-S2`)。
|
||||
|
||||
**不采用**路径 B(以 MCP meta / 已退役的 `schema_mcp_metadata` 生成全部 CLI flag/help/schema)作为主权威。钉钉 MCP meta 不是飞书 OAPI:粒度与 CLI 特有语义(二选一、OmitEmpty、ConstParams、write guard)无法从裸 meta 推出;强行生成会违反「Schema 描述 CLI,不制造 CLI」。
|
||||
|
||||
路径 B 仅允许作为 **可选的 1:1 MCP 透传叶子通道**(见 §5),不得覆盖 LeafSpec / Shortcut 主路径。
|
||||
|
||||
对外叙事:与飞书 **分层单权威** 同构——API/透传叶可用平台事实,产品 CLI / Shortcut 用手写契约 + 执行体——而不是「全家只有平台 meta」。
|
||||
|
||||
### 1.2 声明(declare):写什么、写在哪、投影到哪
|
||||
|
||||
**定义**:声明 = 在 Contract 结构体的**数据字段**上写出事实;`corecmd.New` 据此注册 cobra、渲染 help、写入 `dws.schema.*`。钩子闭包(`Validate` / `Call` / `PostMount` / `RunE`)里的逻辑**不算**声明——即使行为正确,也不能单靠钩子让 Schema/help「猜出」该事实。
|
||||
|
||||
命令框架边界与今日/目标对应见 RFC [`rfc-command-framework-convergence.md`](rfc-command-framework-convergence.md) **§5.0**(框架上的「声明」定义);本节给字段级表与示例。
|
||||
|
||||
**唯一写入面**(三选一,语义相同):
|
||||
|
||||
| 入口 | 类型 | 归一 |
|
||||
|---|---|---|
|
||||
| Leaf 命令 | `helpers.LeafSpec` | `FromLeafSpec` → `corecmd.Spec` |
|
||||
| Shortcut | Shortcut 声明(经 `FromShortcut`) | → `corecmd.Spec` |
|
||||
| 直接基座 | `corecmd.Spec` | `corecmd.New` |
|
||||
|
||||
今日产品 CLI 叶子以 **`LeafSpec` 为声明门面**;字段与 `corecmd.Spec` 契约面一一对应。
|
||||
|
||||
#### 1.2.1 契约字段(算声明)
|
||||
|
||||
| 字段 | 声明什么 | 运行时 | 嵌入 Schema / help |
|
||||
|---|---|---|---|
|
||||
| `Flags[]`(`FlagSpec` / `LeafFlag`) | 用户可见参数面:名、类型、默认、必填、usage | 注册 cobra flag;装配 toolArgs | `dws.schema.property` / `type` / `required`;`--help` Flags |
|
||||
| `Constraints[]` | 跨 flag 关系:`at_least_one` / `exactly_one` / `mutually_exclusive`;`custom` 记录钩子校验 | 通用关系由 `ValidateConstraints` 执行;`custom` 由 `Validate` 执行 | `dws.schema.constraints`;`--help`「参数约束」 |
|
||||
| `Safety`(`contract.SafetySpec`) | effect/risk/confirmation/idempotency 四个独立事实 | `confirmation=user_required` 时 `ConfirmSafety`;`--yes` / `--dry-run` 跳过 | 同一个 SafetySpec 原样进入 Contract Final(`HOM-S1`) |
|
||||
| `ConstParams` | 固定载荷(不上 flag 表) | 并入 toolArgs;不满足 Required | **不**投影为用户 parameter |
|
||||
| `Use` / `Short` / `Long` / `Example` | 命令身份文案与示例 | cobra 自身 | help;identity 以 collector 收集的 `ContractFinal.Identity` 声明为准(reviewed registry 已退役) |
|
||||
|
||||
`FlagSpec` 子字段(声明细节):
|
||||
|
||||
| 子字段 | 作用 | 是否进 Schema parameters |
|
||||
|---|---|---|
|
||||
| `Name`, `Usage`, `Kind`, `Default` | 注册名/说明/类型/DefValue | 是(name/type;default 与 cobra 对齐) |
|
||||
| `Required` / `MarkRequired` | 非空校验 / cobra 硬必填 | 是(`required`) |
|
||||
| `RequiredHint`, `Aliases`, `EnvVar` | 校验提示、隐藏别名、环境回退 | 否(执行细节;别名不上主 parameter 表) |
|
||||
| `ArgDefault`, `Bind`, `OmitEmpty`, `Trim`, `Transform` | toolArgs 装配语义 | 否(载荷细节;`Bind` 可进 property 映射,但不另造 flag) |
|
||||
|
||||
#### 1.2.2 编排 / 执行字段(不算声明)
|
||||
|
||||
| 字段 | 角色 | 禁止用来「冒充」的声明 |
|
||||
|---|---|---|
|
||||
| `Validate` | 条件式/领域校验钩子 | 不得在此 `Flags().String(...)` 注册业务 flag;不得只靠钩子表达「必填/互斥」而不写 `Flags`/`Constraints` |
|
||||
| `Call` / `Invoke` / `Orchestrate` | 执行体 | 不得 `params[k]=…` 装配业务参数(应在 `Flags`/`ConstParams`) |
|
||||
| `PostMount` | 挂载后收尾(annotate、领域工具注入) | 不得注册业务 flag;分页等横切由领域工具注入并可走 annotate |
|
||||
| `RunE` | 逃生舱(整段手写) | 表面事实仍须 Flags 声明;框架仍按 Safety 执行确认 |
|
||||
| `Server` / `Tool` | MCP 路由 | 不构成 CLI parameter 声明 |
|
||||
|
||||
#### 1.2.3 最小声明示例
|
||||
|
||||
```go
|
||||
// 读:Safety 四字段显式对齐 Schema
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "get", Short: "…", Tool: "…",
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Flags: []LeafFlag{
|
||||
{Name: "unified-app-id", Usage: "…", Bind: "unifiedAppId", Trim: true, Required: true},
|
||||
},
|
||||
Call: devAppCall(runner), PostMount: devAppMeta(tool),
|
||||
})
|
||||
|
||||
// 写:同一个 SafetySpec 同时驱动确认与 Schema
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "publish", Short: "…", Tool: "…",
|
||||
Flags: []LeafFlag{ /* … */ },
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Call: devAppCall(runner), PostMount: devAppMeta(tool),
|
||||
})
|
||||
|
||||
// 迁移期旧写命令:确认走 annotate,见 §1.3 —— 不是新声明面
|
||||
NewLeafCommand(LeafSpec{
|
||||
Use: "create", /* Flags… */,
|
||||
Validate: func(cmd *cobra.Command, _ []string) error {
|
||||
return devAppRequireWriteGuard(cmd, "create") // 执行守卫,不是 Contract 声明
|
||||
},
|
||||
Call: devAppCall(runner),
|
||||
PostMount: devAppMetaWrite(tool), // 人工标注 runtime_gate
|
||||
})
|
||||
```
|
||||
|
||||
**空 `Safety` 的含义**:command 为兼容旧只读叶保留 `read/low/not_required/idempotent` 默认。因此「会改状态却留空 Safety」**不是**合法声明;新 Leaf 必须写完整 SafetySpec,未迁移旧路径则按 §1.3 标注 gate。
|
||||
|
||||
### 1.3 人工标注(annotate):声明的补充通道
|
||||
|
||||
当事实无法或不愿放进 Contract 字段时,必须**显式**落注解 / 评审源,禁止组装期推断:
|
||||
|
||||
| 标注手段 | 典型值 | 何时用 |
|
||||
|---|---|---|
|
||||
| `cli.AnnotateRuntimeGate` / `devAppMetaWrite` | `dws.schema.runtime_gate=devAppRequireWriteGuard` | 尚未迁移到 SafetySpec 的旧写命令(`HOM-S2`) |
|
||||
| `cli.AnnotateRuntimeRisk` | `dws.schema.risk=…` | Shortcut 暂存的旧兼容路径;command/Leaf 禁止新增 |
|
||||
| `cli.AnnotateRuntimeFlag` / Constraints | 与 embed 同形 | 手写 cobra 叶补齐表面(长期应迁入 Contract) |
|
||||
| reviewed `schema_hints/metadata` Safety(已退役) | effect/risk/confirmation | 已删:`schema_hints/` 不得重现;受管命令以 Contract.Safety / gate 为准 |
|
||||
|
||||
标注与声明冲突时:**Contract 声明胜**(路径 A)。标注不得发明未注册的 CLI flag。
|
||||
|
||||
### 1.4 Schema 全覆盖(`ToolSpec` 无空洞)
|
||||
|
||||
`dws schema` 叶子模型是 `cli.ToolSpec`。命令框架必须为**每一个字段组**指定权威;完整矩阵在 RFC **§5.0.4**,摘要:
|
||||
|
||||
| ToolSpec 组 | 权威类 | 框架声明字段 / 其它源 |
|
||||
|---|---|---|
|
||||
| Identity | 声明源(identity collector 收集 `ContractFinal.Identity`;reviewed `schema_command_registry` 已退役) | `ContractDecl.Identity` 声明 |
|
||||
| Display / Title / Description | 声明证据 + 交付偏好(非双权威) | **title**:ContractDecl 优先,否则 Cobra Short,再 MCP;**description**:构造期 Description 必填;Catalog 交付 Long→`cobra_help`,无 Long→`contract_final`;**Short 不进 description**(RFC §5.0.4) |
|
||||
| Parameters.`name/type/required/default/property` | **声明**(或同形 annotate) | `Flags` / `Bind` |
|
||||
| Parameters.`description` | 声明 usage(`FlagSpec.Usage` / ParamDecl) | `schema_hints/` 已退役;不得用 overlay 改 type/required/default |
|
||||
| Parameters.`interface_*` | 评审源 | MCP meta / bindings;**不造 flag** |
|
||||
| Constraints | **声明** | `Constraints` |
|
||||
| Positionals | **声明** 或显式 annotate | 目标 `Args`;禁止推断 |
|
||||
| Safety.`effect/risk/confirmation/idempotency` | **声明**完整 `Safety`,或迁移期 `runtime_gate` / reviewed Safety | 四字段独立;不得互相推导 |
|
||||
| DryRun | 评审源 | dry-run capabilities registry |
|
||||
| Interface | 评审源 | MCP + 内存 inject 的 Agent metadata |
|
||||
| Selection | 声明(ContractFinal / ProductDecl) | `ContractDecl.Selection` / `ProductDecl` |
|
||||
| FieldProvenance / Extensions | 组装派生或评审扩展 | 组装器;与 delivered value 一致 |
|
||||
| (非 Schema parameter)ConstParams | **声明** | 载荷;不上 parameters 表 |
|
||||
|
||||
验收:新增 Schema 字段必须同步改 RFC §5.0.4 + 本表;受管写命令 Safety 不得无主。
|
||||
|
||||
## 2. 字段归属(每一类恰好一个写入者)
|
||||
|
||||
| 字段类 | 权威 | 投影到 |
|
||||
|---|---|---|
|
||||
| flags / defaults / required / enum / 关系约束 / 运行时 Risk | Contract(LeafSpec / `corecmd.Spec` 门面) | cobra、`--help`、Schema `parameters` / constraints / confirmation |
|
||||
| ConstParams、Bind、OmitEmpty、Transform | 同上(载荷声明,不上 flag 表) | toolArgs;Schema 不把 ConstParams 伪装成用户 flag |
|
||||
| canonical path / aliases / navigation / exposure | identity collector 收集的 `ContractFinal.Identity` 声明(reviewed `schema_command_registry` 已退役) | Schema identity |
|
||||
| use_when / avoid_when / examples / agent_summary 文案 | `ContractDecl.Selection` / `ProductDecl` | Schema selection |
|
||||
| RPC tool 形状、`interface_ref`、interface 描述 | leaf `Contract.Interface` + `ParamDecl`(`schema_parameter_mapping_ledger.go` 仅 mapping_exclusions / removals;`schema_mcp_metadata` 已退役) | Schema `interface_*` 字段;**不得创建 flag** |
|
||||
| 参数描述 overlay(可选) | 生产 metadata 壳为空;参数事实走 ParamDecl / FlagSpec | **Contract/cobra 胜** |
|
||||
| 遗留 Safety 文案(迁移期) | 生产 metadata 壳为空;Safety 走 Contract | 以 Contract.Safety / runtime_gate 为准(见 §4) |
|
||||
| dry-run 正能力 | reviewed dry-run registry | Schema `dry_run` |
|
||||
| positionals | Contract Args / 显式 annotate | Schema `positionals` |
|
||||
| FieldProvenance | 组装派生 | Schema provenance(与值一致) |
|
||||
|
||||
Selection **刻意不**由单命令 Contract 取代(RFC 决策 8 / schema 设计硬规则);identity 的历史决策是不进 Contract、归 reviewed `CommandRegistry`,该 registry 已退役,现由 identity collector 收集 `ContractFinal.Identity` 声明(声明即 identity)。**完整无空洞表见 RFC §5.0.4。**
|
||||
|
||||
## 3. 当前缺口与目标闭环
|
||||
|
||||
已具备:
|
||||
|
||||
- Flags / ConstParams / Constraints → 注册、校验与 `ConstraintHelp`;SafetySpec → 运行时 `ConfirmSafety`(command);
|
||||
- Call / Execute 作为执行体;业务参数不得在 Call 内装配(helpers 门禁);
|
||||
- **Contract → Schema 嵌入**:参数/约束写原生 annotation,SafetySpec 与 ContractDecl 注册为类型化 Contract Final 并由 Schema 组装透传;
|
||||
- Selection 权威为 `ContractDecl.Selection` / `ProductDecl`(`contract_final`);`schema_hints/` 已退役。
|
||||
|
||||
已进 CI(`make policy` → `check-schema-catalog.sh` / `check-runtime-confirmation-truth.sh`):
|
||||
|
||||
| Gate ID | CI 入口(`-run` 白名单 / 脚本) |
|
||||
|---|---|
|
||||
| `HOM-P1` / `HOM-D1` | `./internal/app`:`TestFinalSchemaParametersMatchExecutableHelpFlags`、`TestDeliverySchemaParametersMatchExecutableHelpFlags` |
|
||||
| `HOM-P2`(参数映射/bindings 子集) | `./internal/cli`:`TestSchemaParameterBindingsMatchReviewedBaselineAndDeliveryCatalog`、`TestDeliveryCatalogMCPParameterMappingsAreComplete` 等 bindings 门禁 |
|
||||
| `HOM-S1` / `HOM-S2`(confirmation 同源) | `./internal/cli/homology`:`TestUserRequiredSafetyHomologyWithRuntimeGate` + `check-runtime-confirmation-truth.sh`;`./internal/app`:`TestSheetFinalSchemaConfirmationMatchesRuntimeGuards` |
|
||||
| 词汇/决策钉扎 | `./internal/cli/homology`:`TestHomologyDecisionDocPinsPathAAndGateIDs`、`TestMCPPassthroughAdmissionExcludesLeafAndShortcut`、`TestHomologyCIEntrypointsPinned` |
|
||||
|
||||
仍缺(未宣称全量 CI 覆盖):
|
||||
|
||||
1. 独立可执行的 `HOM-P3`(constraints ≡ AnnotateConstraints)与 `HOM-S3`(read 不得误投影 user_required)全量 gate;
|
||||
2. `HOM-I1` 作为单独 gate ID 的显式用例(MCP bindings ⊆ Contract flags 已有映射审计子集,但未钉 `HOM-I1` 标签)。
|
||||
|
||||
已落地(写命令确认语义,`HOM-S2`):
|
||||
|
||||
- `AnnotateRuntimeGate` / `dws.schema.runtime_gate`;Leaf `PostMount: devAppMetaWrite`;手写 delete/robot 等同路径显式标注;
|
||||
- Schema 组装在无 Contract Safety 但有 gate 时 overlay `confirmation=user_required`(`applyContractGateToSafety`);
|
||||
- AST/mount 测试:新 Leaf 须声明完整 SafetySpec;尚未迁移的旧路径须有 runtime_gate。
|
||||
|
||||
## 4. Schema 投影与 Safety 门禁规划
|
||||
|
||||
以下门禁 ID 稳定,便于 CI 认领。§3 表标明哪些已挂入 `check-schema-catalog.sh`。
|
||||
|
||||
| Gate ID | 断言 | 范围 | CI |
|
||||
|---|---|---|---|
|
||||
| `HOM-P1` | 受管 leaf 的 schema `parameters[].name` 集合 ≡ cobra 本地 flag 名集合(排除全局 persistent) | LeafSpec / Contract 编译命令 | **已进**(app help↔schema) |
|
||||
| `HOM-P2` | schema parameter `type` / `required` / `default` 与 cobra DefValue / MarkFlagRequired / FlagSpec 一致;不得用已退役 hints overlay 改写这三项 | 同上 | **部分**(bindings/mapping 门禁) |
|
||||
| `HOM-P3` | schema 关系约束(require_one_of / mutually_exclusive)≡ Contract/Leaf `Constraints` 投影(与 `AnnotateConstraints` 同构) | 声明了 Constraints 的命令 | 规划 |
|
||||
| `HOM-S1` | Contract/Leaf `user_required` Safety 与运行时 Confirm/gate 同源,且 help Safety 行同语义 | 受管写/破坏性命令 | **已进** |
|
||||
| `HOM-S2` | 若命令走显式 write guard(如 `devAppRequireWriteGuard`)而非完整 SafetySpec,则必须人工标注 `dws.schema.runtime_gate`;Schema 不得呈 `confirmation=not_required`;符合 §1.1 declare OR annotate | 今日 devapp 写命令 | **已进**(同源测试含 gate 路径) |
|
||||
| `HOM-S3` | `Risk=read`(或空→read)不得投影为 `user_required`,除非有 reviewed exclusion reason | 受管读命令 | 规划 |
|
||||
| `HOM-I1` | `interface_ref` 存在时,bindings 覆盖的 CLI flag ⊆ Contract flags;MCP meta **不**引入额外 CLI flag | 有 MCP 绑定的命令 | **部分**(mapping 审计) |
|
||||
| `HOM-D1` | `dws <path> --help` Flags 段与 schema leaf parameters 零未解释增量 | 受管公开 leaf | **已进**(与 HOM-P1 同测) |
|
||||
|
||||
落地顺序建议:
|
||||
|
||||
1. 保持 `HOM-P1`/`HOM-D1`/`HOM-S1`/`HOM-S2` 在 `check-schema-catalog.sh` 白名单中(勿再只靠词汇钉扎);
|
||||
2. 补独立 `HOM-P3` / `HOM-S3` 可执行 gate,并把 `HOM-P2`/`HOM-I1` 从「部分」升到全量标签;
|
||||
3. 新 Leaf 继续走 Contract 嵌入;禁止 `schema_hints/` 回潮。
|
||||
|
||||
## 5. 路径 B 子通道:1:1 MCP 透传叶(可选,非主路径)
|
||||
|
||||
仅当同时满足以下条件时,才允许「从 MCP meta 生成 flag/help/schema 参数」:
|
||||
|
||||
1. CLI path ↔ 单一 MCP tool **严格 1:1**,无多步、无按名解析、无本地 effect;
|
||||
2. 无不在 MCP input schema 中的 CLI 特有 flag(含 guard 专用语义 flag 除外的全局 `--yes`/`--dry-run`);
|
||||
3. 无 ConstParams / Transform / 跨 flag 约束 / Call 内业务逻辑;
|
||||
4. Risk/confirmation 在 meta 或并列 reviewed Safety 中有显式来源,不靠生成器猜测;
|
||||
5. 在 identity 声明面标记 `surface_kind=mcp_passthrough`(名称可调整;原计划标在 reviewed registry,该 registry 已退役),且 **不得**与 LeafSpec/Shortcut 手写定义双注册同一 `cli_path`;
|
||||
6. 文档与门禁写明:该通道是子集优化,失败时回退/禁止扩张到产品 CLI。
|
||||
|
||||
显式排除(永远走路径 A / Shortcut):
|
||||
|
||||
- 全部 `LeafSpec` 命令(含 `dws dev app …`);
|
||||
- 全部 `+shortcut` 与 smart 编排;
|
||||
- 任何需要 `devAppRequireWriteGuard`、cursor 工具注入、或响应投影的命令。
|
||||
|
||||
## 6. 非目标
|
||||
|
||||
- ~~不把 canonical identity / 导航塞进 Contract(仍归 reviewed `CommandRegistry`)~~ —— 该非目标已被后续演进取代:reviewed `CommandRegistry` 已退役,identity 现由 collector 收集 `ContractFinal.Identity` 声明(声明即 identity,不再是独立评审文件)。selection 文案已由 `ContractDecl.Selection` / `ProductDecl` 声明(非 hints)。
|
||||
- 不要求删除 LeafSpec 门面。
|
||||
- 不把「生成 catalog 字节一致」当作运行时同源的充分条件(仍需 `HOM-*` 与差分门禁)。
|
||||
File diff suppressed because it is too large
Load Diff
+73
-12
@@ -5,6 +5,8 @@
|
||||
| Variable | Purpose / 用途 |
|
||||
|---------|---------|
|
||||
| `DWS_CONFIG_DIR` | Override default config directory / 覆盖默认配置目录 |
|
||||
| `DWS_AGENT_PRODUCT` | Optional, caller-declared Agent product sent as `x-dws-agent-product` (for example `qwenwork`) for downstream logs/BI and used as the IM `clawType` display label when `--ai-tag` is enabled. `--ai-tag` defaults to `true`, so a configured Product changes the displayed label by default. With `--ai-tag=false`, native `chat message send` / `reply` calls send an empty `clawType`, while shortcut calls omit the argument. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[A-Za-z0-9][A-Za-z0-9_-]*$`. Unset or empty values omit the Header and use the edition's IM display default. This client never uses Product to change the separate HTTP `claw-type` PAT/routing label. / 可选、由调用方声明的 Agent 产品标识,经校验后作为 `x-dws-agent-product` 发送,并用于 IM 小尾巴;`--ai-tag` 默认为 `true`,因此配置 Product 后默认会改变展示标签。使用 `--ai-tag=false` 时,原生 `chat message send` / `reply` 发送空的 `clawType`,shortcut 调用则省略该参数。未设置时省略请求头且 IM 使用发行版默认值;本客户端不会用 Product 修改独立的 HTTP `claw-type` |
|
||||
| `DWS_AGENT_HOST` | Optional, caller-declared Agent runtime form sent as `x-dws-agent-host` (for example `cloud` or `desktop`) for downstream logs/BI. Surrounding ASCII spaces/tabs are trimmed; the remaining value must be at most 64 bytes and match `^[a-z0-9][a-z0-9_-]*$`; unset values are omitted. This client does not use Host for PAT, authentication, Discovery, or MCP endpoint selection. / 可选、由调用方声明的 Agent 运行形态,经校验后作为 `x-dws-agent-host` 发送给下游日志/BI;本客户端不使用该值进行 PAT、鉴权、Discovery 或 MCP 端点选择,未设置时省略 |
|
||||
| `DWS_<PRODUCT>_MCP_URL` | Override a product MCP endpoint for local development / 本地开发时覆盖指定产品 MCP endpoint |
|
||||
| `DWS_CLIENT_ID` | OAuth client ID (DingTalk AppKey) |
|
||||
| `DWS_CLIENT_SECRET` | OAuth client secret (DingTalk AppSecret) |
|
||||
@@ -12,6 +14,64 @@
|
||||
| `DWS_ALLOW_HTTP_ENDPOINTS` | Set `1` to allow HTTP for loopback during dev / 设为 `1` 允许回环地址 HTTP,仅用于开发调试 |
|
||||
| `DWS_DISABLE_KEYCHAIN` | macOS only. Set `1` to skip system Keychain for the encryption key and use file-based storage (same scheme as Linux). For sandboxed runtimes (e.g. Codex App) that block Keychain APIs. Weakens at-rest protection — DEK and ciphertext live in the same directory. / 仅 macOS。设为 `1` 时跳过系统 Keychain,密钥以文件形式存储(与 Linux 一致)。用于 Keychain API 被拦截的沙盒环境(如 Codex App)。代价是 DEK 与密文同目录,保护强度低于默认方案 |
|
||||
|
||||
### Agent Product, Host, and `claw-type` / Agent 产品、运行形态与 `claw-type`
|
||||
|
||||
`DWS_AGENT_PRODUCT` and `DWS_AGENT_HOST` are caller-declared observation
|
||||
signals. They are not credentials, attestations, or proof of the calling
|
||||
host's identity. The CLI validates and emits `x-dws-agent-product` and
|
||||
`x-dws-agent-host`, but does not use either value to derive its authentication,
|
||||
PAT mode, Discovery behaviour, or ordinary MCP endpoint selection. Downstream
|
||||
services own and must document their own contracts for these caller-declared
|
||||
Headers.
|
||||
|
||||
Service integrators should treat both Headers as untrusted input, allowlist
|
||||
expected values, and should not grant access, bypass authentication, or skip
|
||||
authorization solely because a Header claims a particular Product or Host.
|
||||
|
||||
The HTTP `claw-type` Header is a separate, edition-fixed PAT/routing label:
|
||||
`openClaw` in the open-source build. `DWS_AGENT_PRODUCT` never changes it or
|
||||
PAT `hostControl.clawType`. On IM send/reply operations with `--ai-tag`,
|
||||
however, a valid non-empty Product value is used as the `clawType` tool
|
||||
argument so the delivered message carries the matching “Send from AI” label.
|
||||
Because `--ai-tag` defaults to `true`, this display change is enabled by
|
||||
default for callers that set Product. With `--ai-tag=false`, native
|
||||
`chat message send` / `reply` calls serialize `clawType: ""`, while shortcut
|
||||
calls omit the argument; this client does not assume downstream services treat
|
||||
an empty value and an absent key as equivalent. The display-value precedence
|
||||
when the tag is enabled is valid non-empty `DWS_AGENT_PRODUCT`, then the active
|
||||
edition's `ClawTypeValue`, then `openClaw`.
|
||||
|
||||
Do not set arbitrary Product values that the target downstream and IM services
|
||||
have not explicitly enabled; an unknown value may be ignored or may not render
|
||||
the expected label.
|
||||
|
||||
For QwenWork, report the dimensions separately:
|
||||
|
||||
```bash
|
||||
DWS_AGENT_PRODUCT=qwenwork
|
||||
DWS_AGENT_HOST=cloud # or desktop
|
||||
```
|
||||
|
||||
Older combined Host labels such as `qwenwork_cloud` still satisfy the generic
|
||||
syntax for compatibility, but new integrations should use the two-dimensional
|
||||
convention above.
|
||||
|
||||
`DWS_AGENT_PRODUCT` 和 `DWS_AGENT_HOST` 均由调用方声明,不是认证凭据,也不能证明
|
||||
真实宿主身份。CLI 只负责校验并发送 `x-dws-agent-product` 与 `x-dws-agent-host`,
|
||||
不会用它们派生本客户端的鉴权、PAT 模式、Discovery 行为或 MCP 端点;下游服务的
|
||||
使用契约由对应服务自行定义和说明。HTTP `claw-type` 是发行版固定的 PAT/路由标签,
|
||||
开源版固定为 `openClaw`,不受 `DWS_AGENT_PRODUCT` 影响。
|
||||
|
||||
服务集成方应将这两个请求头视为不可信输入并对白名单值做校验,不应仅因请求头声明了
|
||||
某个 Product 或 Host 就授予访问、绕过认证或跳过鉴权。
|
||||
|
||||
`--ai-tag` 默认为 `true`,因此配置合法非空 Product 后,默认发送的 IM 工具参数
|
||||
`clawType` 及小尾巴会随之改变。传入 `--ai-tag=false` 时,原生
|
||||
`chat message send` / `reply` 会发送 `clawType: ""`,shortcut 调用则省略该参数;
|
||||
本客户端不假定下游会将空值与键缺失等价处理。启用小尾巴时,展示值优先级依次为
|
||||
`DWS_AGENT_PRODUCT`、当前发行版的 `ClawTypeValue`、`openClaw`。不要传入目标下游及
|
||||
IM 服务未明确支持的 Product 值,否则可能被忽略或无法展示预期标签。
|
||||
|
||||
## Exit Codes / 退出码
|
||||
|
||||
| Code | Category | Description / 描述 |
|
||||
@@ -34,7 +94,7 @@ With `-f json`, error responses include structured payloads: `category`, `reason
|
||||
dws contact user search --query "Alice" -f table # Table (default, human-friendly / 表格,默认)
|
||||
dws contact user search --query "Alice" -f json # JSON (for agents and piping / 适合 agent)
|
||||
dws contact user search --query "Alice" -f raw # Raw API response / 原始响应
|
||||
dws schema -f pretty "calendar event create" # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
dws schema -f pretty "calendar event create" --compact # Pretty Agent schema view / Agent Schema 彩色查看
|
||||
```
|
||||
|
||||
## Dry Run / 试运行
|
||||
@@ -51,35 +111,36 @@ dws contact user search --query "Alice" -o result.json
|
||||
|
||||
## Schema Introspection / Schema 查询
|
||||
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本内嵌的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
`--help` 展示当前二进制的 Cobra 命令和可接受 flag,`dws schema` 查询同版本运行时组装的 Agent 命令契约。Schema 查询不访问 MCP endpoint、不执行 `tools/list`,也不搜索钉钉文档或任何业务数据。
|
||||
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 来自 reviewed `CommandRegistry`,并在发布时逐项绑定当前 Cobra tree。编辑 `internal/cli/schema_command_registry.json` 时必须遵守同目录的 `schema_command_registry.schema.json`;普通生成流程只校验该 reviewed input,不会覆盖它。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
Schema 的稳定 `canonical_path`、主 CLI 路径和 aliases 收集自命令树叶节点上的 `ContractFinal.Identity`(`CollectIdentitySpecs`),并在发布时逐项绑定当前 Cobra tree。原 reviewed `schema_command_registry/` 已退役,身份变化通过编辑叶节点声明完成。Native annotation 只做实现一致性校验;Catalog 是该统一强类型契约的发布输出,不作为命令发现或下一轮生成的输入。
|
||||
|
||||
### 路径写法
|
||||
|
||||
```bash
|
||||
dws schema # 当前公开产品面的紧凑概览
|
||||
dws schema calendar # 展开一个产品
|
||||
dws schema "calendar event" # 展开一个命令分组
|
||||
dws schema "calendar event create" # 按 CLI 空格路径查询工具
|
||||
dws schema calendar.create_calendar_event # 按 canonical path 查询工具
|
||||
dws schema --cli-path "calendar event create" # 显式 CLI path
|
||||
dws schema "calendar event create" --compact # 支持:省略 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品视图
|
||||
dws schema "calendar event" --compact # Agent 分组视图
|
||||
dws schema "calendar event create" --compact # Agent leaf(CLI 空格路径)
|
||||
dws schema calendar.create_calendar_event --compact # Agent leaf(canonical path)
|
||||
dws schema --cli-path "calendar event create" --compact # Agent leaf(显式 CLI path)
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 全部工具的完整 leaf Schema,用于审计/CI/baseline
|
||||
```
|
||||
|
||||
兼容入口 `dws schema list` 等价于根概览。`schema --all` 是完整导出:每个工具都包含完整 leaf 参数、约束和安全语义。它输出很大,只用于明确要求的全量导出、审计、CI 或参数 baseline;普通 Agent 任务应按概览、产品/分组、leaf 渐进查询,不要把 `--all` 直接注入上下文。`schema --all --compact` 虽受支持,但会裁掉 provenance 和接口映射字段,不能作为完整 baseline。
|
||||
|
||||
Leaf 查询、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;概览、产品/分组和 Catalog summary 也由该 `ToolSpec` 的统一 summary 投影生成。通过 alias 查询时,只允许 `cli_path` 和 `is_alias` 发生视图变化,参数、安全和接口契约不得变化。
|
||||
省略 `--compact` 的 full leaf、`--all` 中对应工具和 Catalog full tool 均由同一个 resolved `ToolSpec` 投影,内容必须一致;compact leaf 仅做字段白名单投影,不重新解析语义。概览、产品/分组和 Catalog summary 也来自同一 `ToolSpec`。通过 alias 查询时,只允许路径视图发生变化,参数、安全和接口契约不得变化。
|
||||
|
||||
`--compact` 是 Schema 的展示选项。当前版本支持该 flag;若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试。这只降低输出裁剪能力,不表示 leaf 不存在,也不能改用 Schema 查询业务数据。
|
||||
`--compact` 是 Schema 的稳定 Agent 字段白名单,也是普通 Agent 查询的规范选项。它保留 CLI 参数、组合约束、选择和安全语义,但有意省略 `interface_ref`、参数 `property/interface_type` 与 provenance。检查这些映射/审计字段时,使用 full leaf 并通过 `--jq` / `--fields` 精确投影。若兼容旧二进制时收到 `unknown_flag: --compact`,用同一个 Schema 查询去掉 `--compact` 重试;这只降低输出裁剪能力,不表示 leaf 缺失。
|
||||
|
||||
### Schema、Help 与业务数据的边界
|
||||
|
||||
| 问题 | 事实源 |
|
||||
|------|--------|
|
||||
| 命令是否由当前二进制暴露、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射与组合约束、risk/confirmation | 对应的 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数与组合约束、risk/confirmation | 对应的 Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定与 provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 当前钉钉中的文档、文件、日程、消息等业务数据 | 实际执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 与 Help 冲突表示发布契约漂移,不能静默猜测。执行参数必须以 Cobra 实际接受的 flag 为准;安全语义冲突时采用更保守的处理(例如先确认)或停止执行并报告漂移。完成命令发现后,仍必须执行真实业务命令;`dws schema` 本身不会读取或搜索业务内容。
|
||||
|
||||
+35
-28
@@ -1,19 +1,28 @@
|
||||
# 发布手册(预发 / 正式)
|
||||
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew 以 workflow 自动创建的 Formula PR 经独立审核合入为交付边界。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
发布只走一条受控链路:GitHub Actions 的 `Release` workflow 负责版本分配、封板、构建、签名和下游发布;Homebrew Formula 在不可变 Release 资产及 checksum 通过校验后,由同一 workflow 直接写入 `main`,不再创建二次 PR。本地 `dws-release` 仍是兼容入口,但不再要求某一台固定电脑承担打包;不要直接运行 `goreleaser release`,也不要手工补打、移动或复用 tag。
|
||||
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端和本地入口都会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context 和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
发布前必须完成平台治理:目标 GitHub 仓库已启用 immutable releases,`main` 精确要求 `CI` workflow 的九个 context:`Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP`。云端入口会在封 tag 前检查 immutable releases、当前 SHA 的全部九个 context、Environment 保护规则和在途 Release;`v*` tag ruleset 仍需仓库管理员预先配置。
|
||||
|
||||
## 推荐入口:GitHub 云端发布
|
||||
|
||||
任何具有仓库写权限、因而可以手动运行 Actions workflow 的成员,都可以基于当时最新的 `main` 发起发布:
|
||||
入口页面是 [GitHub Actions → Release](https://github.com/DingTalk-Real-AI/dingtalk-workspace-cli/actions/workflows/release.yml)。在仓库页面依次点击 `Actions` → `Release` → `Run workflow` 即可操作;不需要通过 Agent 或本地机器触发。
|
||||
|
||||
1. 在 GitHub Actions 打开 `Release`,选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
只有得到该仓库明确授权、最终权限为 `write`、`maintain` 或 `admin` 的协作者可以运行发布操作,workflow 还会对发起人和重新运行者做同样的权限复核。没有仓库写权限的外部贡献者以及仅有 `read` / `triage` 权限的成员不能规划或发布版本。两个渠道的授权边界如下:
|
||||
|
||||
- beta:上述任一内部成员都可以直接规划和发布,不需要人工审批。
|
||||
- stable:上述任一内部成员都可以规划并发起发布;完成只读规划和治理检查后,workflow 会在 `release-stable` Environment 等待另一名仓库管理员通过 `Review deployments` 签收。申请人不能批准自己的请求,批准后原 run 自动继续,无需重新触发。
|
||||
|
||||
基于当时最新的 `main` 发起发布:
|
||||
|
||||
1. 在上述 `Release` 页面选择 `Run workflow`,分支必须是默认分支 `main`。
|
||||
2. `release_operation=plan`,选择 `release_channel=beta|stable`;仅在开始新 beta 线时选择 `release_bump=patch|minor|major`。
|
||||
3. workflow summary 会给出唯一的下一版本。把对应的精确 `CHANGELOG.md` 章节通过 PR 合入 `main`。
|
||||
4. 再次运行,改为 `release_operation=publish`,并输入 `PUBLISH beta` 或 `PUBLISH stable`。
|
||||
4. 再次运行,改为 `release_operation=publish`。beta 会直接进入自动化发布;stable 会在封 tag 前等待管理员签收。
|
||||
|
||||
`plan` 是纯只读操作,不创建 tag、预留版本号或生成包。CHANGELOG 合入期间若另一个发布先占用了该版本,`publish` 会重新分配并因 CHANGELOG 章节不匹配而拒绝,需要重新 plan。`publish` 会先再次确认 dispatch SHA 仍是当前 `main`、Code Admission 和平台治理均通过,再由唯一的 write job 使用 GitHub API 原子创建 annotated tag;同一次 run 随即进入既有的跨平台构建、GitHub/npm、可选 OSS/Gitee 发布和 Homebrew PR DAG。内置 `GITHUB_TOKEN` 创建的 tag 不依赖第二条 workflow 被再次触发。
|
||||
`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 全部成功。
|
||||
|
||||
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 证明。
|
||||
|
||||
@@ -62,7 +71,9 @@ dws-release
|
||||
dws-release config --remote origin
|
||||
```
|
||||
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。只有显式增加 `--publish` 才会进入 tag 发布,且底层仍要求最终版本确认。
|
||||
之后命令按仓库状态自动走到正确步骤:缺少精确 CHANGELOG 章节时只生成模板并停止;补全、提交并合入 `main` 后,再运行同一条命令就会安全快进本地 `main` 并执行完整预检。若同名 remote 后续被改指向其他仓库会直接拒绝。官方仓库不再接受本地 `--publish`,命令会直接提示上述 Actions 页面;本地入口不能绕过 beta/stable 的统一授权。
|
||||
|
||||
Release workflow 不再监听新建的 `v*` tag;直接推 tag 不会发布 GitHub Release、npm 或镜像渠道。所有新 beta/stable 都必须从云端页面进入统一授权和审计链路;历史失败版本仍可通过受保护的 recovery 兼容处理。
|
||||
|
||||
## 发布模型
|
||||
|
||||
@@ -89,13 +100,7 @@ dws-release v1.2.3-beta.1
|
||||
dws-release v1.2.3-beta.1
|
||||
```
|
||||
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后会在当前 Git worktree 的私有 Git 状态目录写入一个有效期六小时的证明,绑定版本、精确 commit、发布仓库、beta/stable 基线和远端 `main`:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
若源码、版本、远端身份和 stable 基线均未变化,`--publish` 会复用该证明,只执行远端契约、发布身份和最终治理复核,不再重复测试与打包。也可以直接运行 `--publish`;没有可复用证明时只会完整执行一次预检。命令在封 tag 前仍要求再次输入完整版本号,统一入口不提供跳过确认的参数。
|
||||
预检包含测试、策略检查、旧正式版命令树兼容检查、全平台打包、npm 安装验证,以及 macOS 环境下的 Homebrew 安装验证。它还会从默认分支触发一次无发布权限的 `Release governance preflight`,用正式流水线相同的身份检查该精确 commit 的九个 Code Admission context 和 immutable releases。通过后回到上述 Actions 页面选择 beta 和 `release_operation=publish`;云端会重新绑定当前 `main`,然后直接进入 beta 自动发布,不需要人工审批或输入确认短语。
|
||||
|
||||
## 正式发布
|
||||
|
||||
@@ -105,14 +110,13 @@ beta 验证通过后,运行正式版入口:
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
```
|
||||
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检,确认后增加 `--publish`:
|
||||
首次运行只生成正式版 CHANGELOG 并停止。补全内容、删除 `TODO`,提交后通过 PR 合入 `main`;重新运行同一条命令做完整预检:
|
||||
|
||||
```bash
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1
|
||||
dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
```
|
||||
|
||||
本地入口的 `FROM_BETA` 不会自动推断;云端入口会按上述规则唯一选择。两种入口都会把它写入 stable annotated tag 的 `From-Beta` 元数据,CI 会再次读取和验证。
|
||||
预检通过后,在 Actions 页面选择 stable 和 `release_operation=publish`。云端入口会按上述规则唯一选择 beta,并把它写入 stable annotated tag 的 `From-Beta` 元数据;在创建 tag 前必须由另一名仓库管理员签收。
|
||||
|
||||
## CHANGELOG 契约
|
||||
|
||||
@@ -130,14 +134,14 @@ dws-release v1.2.3 --from-beta v1.2.3-beta.1 --publish
|
||||
|
||||
## 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 必须是 annotated tag;本地脚本要求封板提交已通过 PR 合入并包含在远端 `main` 历史中,发布只推送 tag。CI 允许其后 `main` 继续前进,但始终要求封板提交位于 `main` 历史中。
|
||||
- 只接受 `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 重新比较。
|
||||
- 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`,不会覆盖稳定入口。
|
||||
- Release workflow 使用一个最多容纳 100 个 pending run 的串行 publication queue;版本规划、云端封板、发布、恢复、修复和撤回共享同一发布锁。
|
||||
- 本地 tag push 失败时会删除本次新建的本地 tag。远端 tag 一旦创建,后续发布归 CI 所有;发布中途失败时走受保护恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
- 云端 seal 创建远端 tag 后,后续发布归同一 run 所有;发布中途失败时先重跑同一 run 的失败 jobs,必须跨 run 时走机器核验恢复,禁止改 tag 指向或复用版本号。只有已经公开版本经过受保护的全渠道撤回并留下永久 `withdrawn/...` 墓碑后,撤回 workflow 才会在最后一步删除原 tag。
|
||||
|
||||
npm 补发只允许从默认分支触发 Release workflow 的 `repair_npm_version`。它只支持启用 immutable releases 后、由本流水线成功产出的公开 immutable release:目标必须是 `main` 历史中的 annotated tag,并且同 commit 的 `Build immutable GitHub Release` job 已成功。即使后续 npm 分发失败,这个独立的产物封存边界仍可作为补发依据。补发会用目标 commit 的 npm 模板重组包,逐平台核验资产和二进制版本,再发布到隔离的 `backfill` dist-tag,不会回滚 `latest` / `beta`。历史 mutable release 不进入自动补发路径,避免把可被替换的资产带入 npm。
|
||||
|
||||
@@ -164,32 +168,35 @@ dws-release recover v1.2.3-beta.1
|
||||
|
||||
- 输入精确绑定原 annotated tag object、commit 和失败的 sealed `Release` run;云端 run 还必须与 tag 内的 run ID、attempt、requester 完全一致,commit 必须仍在 `main` 历史中。
|
||||
- 目标只允许不存在 GitHub Release 或仍为 Draft;已经公开的版本不能全量重建:单个下游故障走对应的 channel repair,版本本身有问题则走受保护的全平台 withdrawal。
|
||||
- `release-recovery` environment 必须限制为受保护分支、配置至少一名 required reviewer,并禁止自审;workflow 会通过 API 复核这些设置,未配置时 fail closed。
|
||||
- 恢复不再进入人工审批 environment。workflow 会机器核验 tag object、commit、原失败 run/attempt、请求人、完整 seal metadata、`main` 祖先关系以及 Release 状态;任一事实不一致都会在构建前 fail closed。
|
||||
- 恢复复用正常的 contract、构建、Developer ID 签名、资产校验、immutable 发布、Homebrew、npm,以及已启用的 OSS jobs,不存在 recovery 专用 publisher 或门禁跳过。
|
||||
- 如果 GitHub Release 已在 recovery 中封存、后续 Homebrew/npm 校验发生瞬时失败,只重跑该 run 的 failed jobs;流水线仅在隐藏 run marker、tag object、commit 和 finalized artifact 字节全部精确一致时复用公开 Release。
|
||||
|
||||
成功的默认分支恢复 run 会成为后续 beta → stable 和 stable baseline 验证的可审计交付证据;历史临时分支恢复仍只接受 reviewed manifest 中的固定证据。
|
||||
|
||||
云端 seal 后不要使用 GitHub 的 “Re-run failed jobs” 作为交付修复:annotated tag 永久绑定最初的 run attempt,普通 rerun 不会成为可接受的交付证据。GitHub Release 尚未公开时走上述 protected recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
seal job 写入 tag 后如果只因 GitHub API 瞬时 404/429/5xx 或后续 job 失败,可直接使用 GitHub 的 “Re-run failed jobs”。同一 run 会精确复用原 release-plan;seal 只在 version、tag object、commit、channel、beta 来源、OSS policy、请求人、run ID 和完整 message 全部匹配且原 attempt 不大于当前 attempt 时认领已有 tag。不同 run 或任一字段不匹配时不会认领。GitHub Release 尚未公开且必须跨 run 重建时走上述机器核验 recovery;已经公开且仅 npm/OSS/Gitee 某一渠道失败时走对应 repair;版本内容本身有问题时走 withdrawal。
|
||||
|
||||
OSS 的 `latest.txt` / `beta.txt` 是镜像频道元数据;当前仓库安装器仍主要从 GitHub/Gitee 解析版本。启用 OSS 后,发布和撤回把它作为受控分发渠道处理,保证一旦外部消费者接入该 pointer,也不会继续解析到已撤回版本;未启用时两条流程都明确跳过不存在的 OSS 渠道。
|
||||
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并分别为 stable/beta 打开 Homebrew PR;tap 的默认分支仍以独立审核合入为交付边界。撤回 workflow 使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
Release workflow 会生成 Darwin/Linux 双架构 Formula,并在不可变资产逐个校验后,由 `HOMEBREW_PR_TOKEN` 所属的受控发布身份只提交对应 stable 或 beta Formula 文件到 `main`,不再创建二次发布 PR;并发 `main` 更新会以全新 clone 最多重试三次,绝不 force push。该身份的提交不会依赖另一轮 CI 来补齐证明:workflow 只在确认该 commit 单父、唯一改动为目标 Formula、内容与本次已验证产物逐字节一致,且父 commit 九项 Code Admission 全绿后,直接为 Formula-only commit 封存同名九项成功 checks,避免下一次发布因缺失 contexts 被卡住。撤回 workflow 暂时仍使用相同模板和回退版本 checksums 打开反向 PR;问题 GitHub Release 会先被移除以阻止新安装,永久墓碑和 workflow 日志承担审计/续跑依据。
|
||||
|
||||
## 平台治理前置
|
||||
|
||||
仓库管理员还需要在 GitHub 平台配置以下不可由脚本替代的规则:
|
||||
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;tag workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- `main` 必须精确要求 `Lint`、`Test`、`Coverage`、`Policy`、`Edition`、`Interface Integrity`、`AI Behavior`、`CLI Smoke`、`Mock MCP` 九个 Code Admission context;Release workflow 也会通过 Checks API 再确认该封板 SHA 上九项全部成功。
|
||||
- 必须启用 immutable releases;它只保护启用后发布的 release,因此应在第一次使用新流水线前配置。为 `v*` 增加 tag ruleset,限制创建权限,并在 release 发布前保护 tag 的短暂窗口。
|
||||
- tag ruleset 还必须覆盖 `withdrawn/v*`:只允许受保护的撤回 workflow 创建墓碑,禁止更新或删除墓碑;同时应允许 Release workflow 创建新的 `v*`,允许撤回 workflow 在全部渠道回退后删除精确的问题 `v*`。若组织级规则阻止这两个 workflow 的预期动作,发布或撤回会 fail closed,不能靠手工移动 tag 绕过。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和 tag workflow 都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `RELEASE_GOVERNANCE_TOKEN` Actions secret,只授予目标仓库 `Administration: read`;内置 `GITHUB_TOKEN` 不具备 immutable-releases API 所需的仓库治理权限。每次本地预检和云端发布都使用这一个身份进行 fail-closed 验证。
|
||||
- 配置 `APPLE_CERTIFICATE_P12_BASE64`、`APPLE_CERTIFICATE_PASSWORD` 和具备发布权限的 `NPM_TOKEN`;撤回还要求该 npm 身份能够执行 `deprecate` 和修改 dist-tag。
|
||||
- 启用 OSS 镜像时,先创建有效 Bucket,再设置仓库变量 `ENABLE_OSS_MIRROR=true`,并配置 `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET`、`OSS_ENDPOINT`、`OSS_BUCKET`,按需配置 `OSS_PREFIX`。启用后发布保持 fail-closed;撤回身份必须能够补齐安全版本资产、写 `latest.txt` / `beta.txt` 并删除问题版本前缀。尚未 provision Bucket 时保持该变量未设置或不等于 `true`,新 tag 会封存 `OSS-Mirror: deferred` 并跳过 OSS;该版本不能通过现有 repair 流程事后改成启用。
|
||||
- 若启用 Gitee fallback,设置 `ENABLE_GITEE_UPLOAD_FALLBACK=true`,并配置 `GITEE_TOKEN`、`GITEE_USER`、`GITEE_REPO`;该身份必须能够创建和删除目标仓库的 Release 与 tag。
|
||||
- 单独配置 `HOMEBREW_PR_TOKEN`,优先使用仅授权本仓库且具备 `Contents: write`、`Pull requests: write` 的 fine-grained PAT;若组织策略不允许该账号使用 fine-grained PAT,则回退到仅带 `public_repo` scope 的专用 classic PAT。治理预检和 tag contract 会验证 token 身份、classic scope,并用 `[skip ci]` 临时分支和 draft PR 完成真实写权限 canary,随后立即关闭 PR、删除分支;任何清理失败都会 fail closed。门禁也会拒绝与治理 token 复用。
|
||||
- 创建 `release-recovery` environment,只允许受保护分支,设置 required reviewer、禁止自审并关闭管理员绕过。workflow 会读取 environment 的 required-reviewer、prevent-self-review 和 protected-branch 规则;规则缺失时紧急恢复会失败,正常 beta/stable tag 发布不受影响。
|
||||
- 正常 Homebrew 发布使用现有的 `HOMEBREW_PR_TOKEN` 直接提交 Formula-only commit,不再创建 Homebrew PR,也不跑权限 canary。GitHub 不允许内置 Actions App 作为当前仓库 ruleset 的 bypass actor,因此两个默认分支 ruleset 都只给该 token 所属的指定发布管理员用户 `always` bypass;仓库脚本仍会限制提交路径、校验 Ruby、禁止 force push,并在并发更新时重新基于最新 `main`。
|
||||
- `HOMEBREW_PR_TOKEN` 应保持仓库范围的 `Contents: write` 与 `Pull requests: write` 权限;后者仅供撤回流程创建回退 PR。不要与 `RELEASE_GOVERNANCE_TOKEN` 复用,并定期审计 token owner 与 ruleset bypass actor 一致。
|
||||
- 创建 `release-beta` environment,只允许受保护分支且不配置 required reviewer;仓库内部 `write`、`maintain`、`admin` 成员的 beta 发布会直接通过该边界。
|
||||
- 创建 `release-stable` environment,只允许受保护分支,以仓库管理员为 required reviewer,禁止申请人自审并关闭管理员绕过。内部成员可以发起 stable,但必须由另一名管理员签收后才能封 tag 和写入任何发布渠道。
|
||||
- Release workflow 会在封 tag 前回读并验证上述两套 Environment 规则;规则缺失、stable reviewer 不再是仓库管理员、或 beta 被误加人工审批时都会 fail closed。
|
||||
- 创建 `release-withdrawal` environment,只允许受保护分支,设置至少一名 required reviewer、禁止申请人自审并关闭管理员绕过。撤回 workflow 会通过 API 复核这些规则;任何一项缺失都会在触碰 npm、OSS、Gitee、Homebrew 或 GitHub Release 前失败。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的 `contents: write`。若上述发布凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
- 仓库或组织的 Actions 策略必须允许 `Release` 与 `Withdraw release` workflow 的 `GITHUB_TOKEN` 获得各 job 声明的权限;正常发布由 `HOMEBREW_PR_TOKEN` 更新两个受控 Formula 路径,内置 token 只承担 workflow 自身声明的封板与校验写入。若撤回凭证采用 environment secret,确认 `release-withdrawal` 审批完成后能够读取撤回所需的 npm、OSS、Gitee 和 Homebrew 凭证。
|
||||
|
||||
immutable releases,或任一 Code Admission context 缺失、未成功时,发布脚本会自动拒绝封 tag。tag ruleset 可能来自组织层,脚本不自动推断其最终作用范围;管理员确认不能省略,脚本约定也不能替代平台强制。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@
|
||||
|
||||
## 第一步:安装 dws
|
||||
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-dev` skill,只需要 curl(无需 go / git)。
|
||||
一键脚本会自动下载最新版二进制 + `dingtalk-misc` skill(开放平台应用文档落在 misc),只需要 curl(无需 go / git)。
|
||||
|
||||
### macOS / Linux
|
||||
|
||||
|
||||
@@ -6,17 +6,21 @@ DWS Schema 是当前二进制公开 CLI 的版本化 Agent 执行契约。它描
|
||||
|
||||
设计遵循三条硬规则:
|
||||
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、manual hint、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。
|
||||
1. **Schema 描述 CLI,不制造 CLI。** `CommandRegistry`、ProductDecl / leaf `Contract`、metadata 和 Catalog 都不能凭空创建 Cobra 命令或 flag;registry 中的每个路径都必须精确绑定真实 runnable Cobra leaf。interface 事实由 leaf `Contract` / `ParamDecl` 声明(`schema_mcp_metadata` 已退役),**不得**从 MCP meta 生成 CLI flag(见 §4.1 同源决策)。
|
||||
2. **所有来源只解析一次。** 来源经过统一 resolver 进入 typed `SchemaRegistry`,所有查询、导出和门禁都消费同一个 `SchemaRegistry/SchemaIndex`。
|
||||
3. **Registry-first,Catalog 只出不进。** reviewed `CommandRegistry` 是稳定 command identity/navigation 的唯一事实源;`schema_catalog.json` 和其他生成 JSON 只是下游发布物,不能成为命令、metadata 或下一轮 Catalog 的来源。运行时 production loader 解码 embedded snapshot 只是交付边界,不是 source resolution。
|
||||
3. **Collector-first,Catalog 只出不进。** identity collector(`CollectIdentitySpecs`,遍历携带 `ContractFinal.Identity` 的 live Cobra leaves)是稳定 command identity/navigation 的唯一事实源;reviewed `schema_command_registry/` 已退役,identity 由声明(Contract)即代码提供,不再有独立的 reviewed identity 文件。production 通过 `RegisterSchemaSourceRoot` → `ResolveSchemaBuild` 组装 `SchemaRegistry`,并从它投影 ToolSpec wire 与 `ResolveMeta`。`cmd_schema_catalog` 只能生成 CI/local dump,`internal/cli/schema_catalog/`、`schema_meta_index.gob` 和 `schema_meta_index.json` 不得提交或成为运行时来源。`schema_agent_metadata/` / `schema_hints/` / `schema_command_registry/` 已退役;若存在则 policy 失败。生产 Agent selection / safety / interface 权威为 leaf `ContractFinal` 与 `ProductDecl`;`agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 仅作 `cmd_schema_catalog` CI/local dump 辅助,不得作为生产权威。
|
||||
|
||||
Schema 不调用 MCP `tools/list`,不访问网络,也不读取用户本地 discovery cache。
|
||||
|
||||
**flag / help / schema 参数面同源**(已决策):Contract / LeafSpec 为 CLI 表面权威;分层字段归属与门禁 ID 见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
|
||||
|
||||
## 2. 单向数据流
|
||||
|
||||
```text
|
||||
schema_command_registry.json (reviewed CommandRegistry source)
|
||||
+ reviewed manual command additions
|
||||
identity collector (CollectIdentitySpecs)
|
||||
walks live Cobra leaves carrying ContractFinal.Identity;
|
||||
reviewed exclusions applied; single identity source
|
||||
(reviewed schema_command_registry/ retired)
|
||||
|
|
||||
v
|
||||
EffectiveCommandRegistry
|
||||
@@ -28,26 +32,13 @@ schema_command_registry.json (reviewed CommandRegistry source)
|
||||
v
|
||||
BoundCommandRegistry
|
||||
|
|
||||
+----------------------+
|
||||
|
|
||||
skills/mono Markdown + internal/cli/schema_hints/*.json |
|
||||
+ schema_mcp_metadata.json |
|
||||
| |
|
||||
v |
|
||||
Agent-metadata normalization |
|
||||
| |
|
||||
v |
|
||||
schema_agent_metadata/*.json |
|
||||
(generated normalized input) |
|
||||
| |
|
||||
+-----------------------+
|
||||
|
|
||||
v
|
||||
live Cobra flag facts / typed parameter metadata
|
||||
+ schema_hints/metadata/*.json (reviewed parameter overlay + safety)
|
||||
+ schema_hints/selection/*.json (reviewed Agent selection prose)
|
||||
+ schema_parameter_bindings.json (reviewed flag -> RPC property)
|
||||
+ schema_mcp_metadata.json (pinned, sanitized interface facts)
|
||||
+ normalized Agent metadata
|
||||
+ leaf Contract (Safety / ContractDecl / ParamDecl → contract_final)
|
||||
+ ProductDecl (product routing prose; production Agent authority)
|
||||
+ schema_parameter_mapping_ledger.go (reviewed mapping exclusions / removals)
|
||||
+ leaf Contract.Interface / ParamDecl (declared interface facts)
|
||||
+ skills/mono Markdown (evidence only; not concatenated)
|
||||
|
|
||||
v
|
||||
source adapters + resolvers
|
||||
@@ -60,17 +51,14 @@ live Cobra flag facts / typed parameter metadata
|
||||
+-----------+-----------+
|
||||
| |
|
||||
v v
|
||||
build-time typed gates snapshot serializer
|
||||
|
|
||||
v
|
||||
schema_catalog.json
|
||||
(release output only)
|
||||
|
|
||||
v
|
||||
go:embed -> typed loader
|
||||
build-time typed gates RegisterSchemaSourceRoot
|
||||
-> ResolveSchemaBuild
|
||||
(runtime assembly; lazy Once)
|
||||
|
|
||||
v
|
||||
SchemaRegistry + SchemaIndex
|
||||
+ ResolveMeta projection cache
|
||||
(in-process map; not gob/json fixture)
|
||||
|
|
||||
+---------------------+------------------+
|
||||
| | |
|
||||
@@ -81,9 +69,12 @@ live Cobra flag facts / typed parameter metadata
|
||||
|
|
||||
v
|
||||
runtime query + delivery gates
|
||||
(cmd_schema_catalog = CI/local dump only;
|
||||
InstallBuildTimeAgentMetadataJSON = dump helper,
|
||||
not production Agent authority)
|
||||
```
|
||||
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、manual hint 或生成 JSON 重新解析 command identity。
|
||||
`--help` 是 Cobra 自身的人类可读投影,不从 Catalog 生成。Schema projections 和 `--help` 共享同一真实 Cobra 命令面,但承担不同职责。Binder 之后不得再从 annotation、已退役 hint overlay 或生成 JSON 重新解析 command identity。
|
||||
|
||||
## 3. 与 Lark 的关系
|
||||
|
||||
@@ -94,8 +85,10 @@ DWS 与 Lark 保持**架构同构**,而不是强行复制字段:
|
||||
| typed command/metadata registry | `EffectiveCommandRegistry`、`BoundCommandRegistry` 与最终 `SchemaRegistry` |
|
||||
| navigation catalog/index | 从同一 `ToolSpec` 派生的 `SchemaIndex` |
|
||||
| schema renderer/envelope | overview、product/group、leaf、`--all` projections |
|
||||
| API Commands ← 平台 OAPI meta | **不**作为 DWS 主路径;可选 1:1 MCP 透传子集见同源文档 §5 |
|
||||
| Shortcuts ← 手写声明 + Execute | LeafSpec / Shortcut + Contract 表面(路径 A) |
|
||||
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 base Registry 与 reviewed manual command additions 在绑定前合并为唯一的 `EffectiveCommandRegistry`,因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。
|
||||
共同点是:强类型 registry 持有已审核、已绑定、已解析的事实,index 只负责确定性导航,renderer 只投影,不重新读取来源或做 precedence。DWS 的 identity collector 从携带 `ContractFinal.Identity` 的 live Cobra leaves 收集 identity,绑定前生成唯一的 `EffectiveCommandRegistry`(reviewed `schema_command_registry/` 已退役),因此不存在 “native-first”、“legacy registry fallback” 或 Catalog fallback。飞书也是**分层单权威**(API 用平台 meta,Shortcut 用手写契约),不是全家只有 meta——DWS 对齐的是这一分层,而不是「用 MCP meta 生成全部 CLI」。
|
||||
|
||||
DWS 内部 resolved model 为:
|
||||
|
||||
@@ -120,26 +113,38 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
|
||||
| 来源 | 负责内容 | 明确不负责 |
|
||||
|---|---|---|
|
||||
| `schema_command_registry.json` | reviewed `CommandRegistry`:稳定 canonical identity、primary CLI path、alias、exposure 和导航 | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed manual command additions | 将一个精确存在的 runnable Cobra leaf 合并进 `EffectiveCommandRegistry`;必须 reviewed 且带 reason | 运行时 fallback、覆盖冲突 identity、创建命令 |
|
||||
| Go/Cobra | 路径是否真实可执行、Cobra 接受的 flag、CLI 类型/默认值、执行校验、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC |
|
||||
| Contract / LeafSpec / `corecmd.Spec` | **CLI 表面权威**:flags、defaults、required、enum、关系约束、运行时 Risk;编译为 cobra 与 help | canonical identity、selection 文案、虚构 RPC |
|
||||
| identity collector(`CollectIdentitySpecs`) | 从 live Cobra leaves 的 `ContractFinal.Identity` 声明收集稳定 canonical identity、primary CLI path、alias、exposure 和导航(reviewed `schema_command_registry/` 已退役) | 创建 Cobra 命令/flag、参数、安全、endpoint/token |
|
||||
| reviewed exclusions(`ReviewedRuntimeSchemaExclusions`) | exact、reviewed、带 reason 地将指定 public runnable leaf 排除出 effective 表面 | 运行时 fallback、prefix/wildcard 排除、创建命令 |
|
||||
| Go/Cobra | Contract 编译后的可执行投影:路径是否真实可执行、Cobra 接受的 flag、DefValue、help 文本 | 稳定 canonical identity、Agent 场景选择、虚构 RPC;**不得**成为与 Contract 平行的第二套 flag 权威 |
|
||||
| native Schema identity annotations | implementation-side consistency evidence;存在时必须与 `EffectiveCommandRegistry` 精确一致 | 提供、补全、推断或覆盖 identity |
|
||||
| `schema_hints/metadata/*.json` parameter overlays | 精确覆盖现有 flag 的描述、映射、类型和 required 语义;并承载 safety / `runtime_gate` / interface | 创建命令/flag、绕过 completeness、虚构 RPC |
|
||||
| typed parameter metadata / constraints | `required_when`、one-of、互斥、联动、格式、枚举、位置参数 | 命令 identity |
|
||||
| `schema_parameter_bindings.json` | 稳定 CLI flag 到 RPC property 的映射 | 命令发现、risk 推断 |
|
||||
| `schema_mcp_metadata.json` | pinned RPC identity、接口描述和脱敏参数事实 | CLI identity、运行时路由、risk 推断 |
|
||||
| `schema_hints/selection/*.json` | reviewed selection prose(summary / use_when / avoid_when / examples) | 创建 Cobra 命令或参数、改写 safety |
|
||||
| typed parameter metadata / constraints | 由 Contract 约束投影而来的 `require_one_of` / 互斥等;以及仍需 reviewed 的 `required_when` 等 | 命令 identity |
|
||||
| `schema_parameter_mapping_ledger.go` | CLI flag 无直接 RPC property 的 exclusions / removals | 命令发现、risk 推断、创建 CLI flag;property 交付归 ParamDecl.Property |
|
||||
| leaf `Contract.Interface` / `ParamDecl` | 声明的 RPC identity 与 interface_* 事实(`schema_mcp_metadata.json` 已退役) | CLI identity、运行时路由、**创建 CLI flag**、risk 推断 |
|
||||
| ProductDecl + leaf `Contract.Selection` | reviewed selection / product routing prose(`contract_final`) | 创建 Cobra 命令或参数、改写 safety;`schema_hints/` 已退役 |
|
||||
| Skills/Markdown | 产品路由、工作流和使用建议 | 命令存在性和 flag 事实 |
|
||||
| `schema_catalog.json` 及其他 generated JSON | resolved registry 的兼容发布序列化;运行时由 production loader 解回 typed registry/index | generation/source resolution 输入、identity fallback、手工修复源 |
|
||||
| `cmd_schema_catalog` CI/local dump(可选 `schema_catalog/` / meta-index) | resolved registry 的兼容序列化快照,仅供 jq/determinism;不得提交为 runtime 来源 | production delivery、`ResolveMeta` 权威、identity fallback、手工修复源 |
|
||||
|
||||
`schema_command_registry.json` 承载 reviewed `CommandRegistry`。Manual command addition 先以确定性规则合并进 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
identity collector 从 live Cobra leaves 的 `ContractFinal.Identity` 声明生成 `CommandSpec`,应用 reviewed exclusions 后按确定性规则索引为 effective registry;从 binder 开始,下游只看到一个稳定 identity/navigation 模型。reviewed `schema_command_registry/` 已退役,其历史角色(稳定 canonical identity/navigation 事实源)由 collector 承接。旧 wire 中的 `surface_hash` / `surface_tools` 字段仅为兼容名称,语义已经是 effective Registry hash/coverage,不构成第二事实源。
|
||||
|
||||
### 4.1 flag / help / schema 同源(路径 A + 嵌入)
|
||||
|
||||
完整决策、字段归属表、`HOM-*` 门禁规划与可选 MCP 透传准入条件见 [`flag-help-schema-homology.md`](flag-help-schema-homology.md)。
|
||||
|
||||
摘要:
|
||||
|
||||
- **同源面**:Contract → cobra flags ≡ `--help` Flags ≡ **嵌入注解后的** schema `parameters` / 关系约束;显式 `Risk` 经 `dws.schema.risk` overlay 进 Schema Safety。
|
||||
- **嵌入点**:`command.embedContractIntoSchema` 写入 `dws.schema.contract` / property / type / required;`AnnotateConstraints` 写入 constraints;Schema 组装(`runtimeToolSpecFromMetadata` / `ResolveSchemaBuild`)消费这些注解进入 typed `SchemaRegistry`(runtime assembly;非 `go:embed` catalog 交付)。
|
||||
- **硬规则**:CLI 表面事实 = **声明(Contract 数据字段)OR 人工标注**;禁止纯推断。非 CLI 表面字段(identity / selection / interface)必须有**评审源**。声明写法见同源文档 §1.2;标注见 §1.3;**`ToolSpec` 全字段权威见 RFC §5.0.4 / 同源 §1.4**。
|
||||
- **非同源面(有意)**:identity(collector)、selection 文案(ProductDecl / leaf `Contract.Selection`)、RPC 形状(MCP meta 仅 `interface_*`)、dry-run 正能力 registry。
|
||||
- **禁止**:以 MCP meta 为主通道生成 Leaf/Shortcut 的 flag;已退役的 hint overlay 改写 type/required/default;Schema 字段无权威归属。
|
||||
|
||||
## 5. 统一解析与 precedence
|
||||
|
||||
### 5.1 Identity
|
||||
|
||||
- Reviewed base `CommandRegistry` 是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源。
|
||||
- Reviewed manual command addition 只能引用精确存在的 runnable Cobra leaf;它在绑定前合并进 `EffectiveCommandRegistry`。若与 base Registry 的 identity/path/alias 冲突,生成失败,不能按 precedence 静默覆盖。
|
||||
- identity collector(`CollectIdentitySpecs`)是 stable canonical identity、primary path、alias 和 navigation 的唯一基础事实源:每个携带 `ContractFinal.Identity` 的 runnable Cobra leaf(含 Hidden deprecated/migration shims)贡献一条 identity,reviewed exclusions 精确应用;reviewed `schema_command_registry/` 已退役,其历史角色由 collector 承接。
|
||||
- Collector 输出的 `CommandSpec` 在索引时 fail-closed 校验 canonical/product/path/alias/visibility 的合法性与唯一性;重复 identity、alias 复用 primary path 或 alias collision 全部失败,不能按 precedence 静默覆盖。
|
||||
- Binder 必须把 effective entry 的 primary path 和每个 alias 精确解析到同一个真实 executable leaf;stale path、phantom path、重复 identity 或 alias collision 全部失败。
|
||||
- Native identity annotation 是可选的一致性证据:存在时必须与 effective entry 精确一致;缺失不触发补写、推断或 fallback。
|
||||
- Public runnable Cobra leaf 未进入 effective registry 时,必须存在 exact、reviewed、带 reason 的 exclusion;不得用 prefix/wildcard 排除。
|
||||
@@ -153,30 +158,35 @@ DWS 当前对外仍保留兼容 wire:leaf 使用 flat `parameters`,安全和
|
||||
constraint、Cobra help 和 interface resolver 提供,因此删除该过渡层没有数据迁移缺口。
|
||||
CI 同时禁止重新加入 generated native contracts 或 materialization 入口。
|
||||
|
||||
#### CommandRegistry 输入审计
|
||||
#### Identity 输入审计(registry 已退役)
|
||||
|
||||
`schema_command_registry.json` 是 reviewed source,不是生成快照。它必须保留
|
||||
`$schema: ./schema_command_registry.schema.json`。该 JSON Schema 对 root、product
|
||||
和 CommandSpec 全部使用 `additionalProperties: false`,并约束:
|
||||
历史上 identity 来自 reviewed `schema_command_registry/`(`registry.json` +
|
||||
`products/*.json`,由随附 JSON Schema 校验)。该 reviewed registry 已退役,
|
||||
改由 identity collector 承接:identity 现在由 `CollectIdentitySpecs` 从 live
|
||||
Cobra 树的 `ContractFinal.Identity` 声明收集,输入契约即声明本身。严格 Go
|
||||
索引(`indexCommandSpecs`)继续 fail-closed 校验:
|
||||
|
||||
- canonical identity、`source_product_id` 和精确 CLI path 的格式;
|
||||
- `aliases` 唯一且不能复用 primary path;
|
||||
- `visibility` 只允许 `public | compat | internal`,省略时明确归一化为
|
||||
`public`;
|
||||
- primary path、alias、canonical 和 product 之间无法由 JSON Schema 表达的
|
||||
交叉约束,继续由 Go strict loader 和 Cobra binder fail-closed 校验。
|
||||
- primary path、alias、canonical 和 product 之间的交叉唯一性约束。
|
||||
|
||||
Registry semantic hash 覆盖 canonical、primary CLI path、alias 集合、
|
||||
Registry semantic hash 仍覆盖 canonical、primary CLI path、alias 集合、
|
||||
`source_product_id` 和 normalized visibility。格式、顺序以及省略的等价默认值
|
||||
不改变 hash;上述任一稳定契约字段变化都必须改变 hash。测试逐字段验证这一点,
|
||||
不使用当前命令数量作为常量。
|
||||
|
||||
普通 `go generate ./internal/cli` 只把 Registry 作为 validation-only 输入并生成
|
||||
Agent metadata/Catalog 等单向下游资产,不生成或覆盖 Registry。drift policy 在生成
|
||||
前后对 reviewed Registry 做 byte-for-byte guard;独立的
|
||||
`check-schema-command-registry.sh` 在 interface/provenance/Catalog policy 之前检查
|
||||
JSON 输入契约、禁用旧 native materialization 符号,并从 Registry 动态计算审计
|
||||
数量,不能硬编码某次快照的 tool count。
|
||||
普通 `go generate ./internal/cli` 只生成
|
||||
`param_aliases_generated.go`;production Catalog / `ResolveMeta` 由 runtime
|
||||
`ResolveSchemaBuild` 装配(`deliverySchemaCatalog` Once 后缓存 Meta 投影)。
|
||||
`cmd_schema_catalog` 仅按需打 CI/local dump;其 `InstallBuildTimeAgentMetadataJSON`
|
||||
inject 仅服务 dump,生产 Agent 权威仍是 leaf `ContractFinal` / `ProductDecl`。
|
||||
不写也不 embed `schema_agent_metadata/`。drift policy 禁止已退役的
|
||||
`schema_command_registry/` 在生成前后重新出现;原独立脚本
|
||||
`check-schema-command-registry.sh` 的 registry-agnostic side guards(禁用旧
|
||||
native materialization 符号、go:generate 单轨、lazy loader 纪律)已迁入
|
||||
`check-schema-catalog.sh`。
|
||||
|
||||
### 5.2 Parameter
|
||||
|
||||
@@ -185,15 +195,17 @@ JSON 输入契约、禁用旧 native materialization 符号,并从 Registry
|
||||
实现中的参数字段顺序固定为:
|
||||
|
||||
```text
|
||||
reviewed manual > versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract > ToolSchemaHint > MCP metadata
|
||||
> inference/default
|
||||
versioned binding > command constraint > typed metadata
|
||||
> native/Cobra contract (ParamDecl / ContractFinal)
|
||||
> MCP metadata > inference/default
|
||||
```
|
||||
|
||||
命令 `title` / `description` 使用独立但同样确定的文本顺序:
|
||||
|
||||
```text
|
||||
reviewed ToolSchemaHint > command-specific Cobra Help > MCP metadata > inference
|
||||
description: Cobra Long > ContractDecl description (contract_final) > MCP metadata > inference
|
||||
(Long 胜出时 provenance = cobra_help / cobra_help_preferred)
|
||||
title: ContractDecl / ContractFinal > Cobra Short > MCP metadata > inference
|
||||
```
|
||||
|
||||
因此多个 CLI leaf 复用同一个 RPC 时,通用 RPC 文案只能作为未选中的
|
||||
@@ -219,7 +231,8 @@ Cobra hard-required 是独立的 executable fact,并通过 `cli_required`/prov
|
||||
| 问题 | 事实源 |
|
||||
|---|---|
|
||||
| 当前二进制是否暴露命令、Cobra 接受哪些 flags | `dws <path> --help` |
|
||||
| Agent 选哪个命令、参数映射/required/约束、risk/confirmation | 对应 leaf `dws schema "<path>"` |
|
||||
| Agent 选哪个命令、CLI 参数/required/约束、risk/confirmation | Agent leaf `dws schema "<path>" --compact` |
|
||||
| CLI↔RPC 参数映射、接口绑定、provenance | full leaf 配合 `--jq` / `--fields` 精确投影 |
|
||||
| 钉钉中的文档、文件、日程、消息等实际数据 | 真正执行 `dws doc read`、`dws drive search` 等 read/search/list 命令 |
|
||||
|
||||
Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
@@ -234,10 +247,10 @@ Schema 和 Help 冲突是契约漂移,不能静默猜测:
|
||||
|
||||
```bash
|
||||
dws schema # 产品紧凑概览
|
||||
dws schema calendar # 产品摘要
|
||||
dws schema "calendar event" # 分组摘要
|
||||
dws schema "calendar event create" # 完整 leaf
|
||||
dws schema "calendar event create" --compact # 支持:裁掉 provenance/debug 字段
|
||||
dws schema calendar --compact # Agent 产品摘要
|
||||
dws schema "calendar event" --compact # Agent 分组摘要
|
||||
dws schema "calendar event create" --compact # Agent leaf
|
||||
dws schema "calendar event create" # full leaf,仅用于映射/provenance 审计
|
||||
dws schema --all # 所有工具的完整 leaf 导出
|
||||
```
|
||||
|
||||
@@ -245,36 +258,30 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
`schema --all` 必须包含最终 `SchemaIndex` 中每个 tool 的完整 leaf 参数、约束和安全语义;无业务参数的命令也要包含空 `parameters` 对象。它用于审计、CI 和参数防丢 baseline,但输出很大,普通 Agent 命令发现不得使用,应按 overview -> product/group -> leaf 渐进查询。
|
||||
|
||||
`--compact` 当前受支持,适合减少常规 leaf 查询上下文。`schema --all --compact` 也可执行,但会移除 provenance/debug 和接口映射字段,不能作为完整兼容性 baseline。
|
||||
`--compact` 是普通 Agent 查询的规范视图:通过正向字段白名单保留选参、约束与安全语义,full 新增字段不会自动进入 Agent 上下文。省略它的 leaf 包含参数 property、接口绑定和 provenance,只用于定向审计;`schema --all --compact` 也可执行,但不能作为完整兼容性 baseline。
|
||||
|
||||
兼容旧二进制时,如果 Schema 查询返回 `unknown_flag: --compact`,只去掉 `--compact` 重试同一个查询。这是展示能力降级,不代表 leaf 缺失,也不能改用 Schema 查询业务数据。
|
||||
|
||||
## 8. 生成与发布
|
||||
|
||||
当 Cobra、flag、identity、binding、manual hint、Agent hint 或 Skill 发生变化时:
|
||||
当 Cobra、flag、identity、binding、leaf `Contract` / ProductDecl 或 Skill 发生变化时:
|
||||
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 时,精确编辑 reviewed `CommandRegistry`(当前持久化文件为 `schema_command_registry.json`)。参数、Skill 或 metadata 单独变化时不要机械改写 Registry,也不要从旧 Catalog 反向生成它。
|
||||
2. 仅对明确例外使用 reviewed manual command addition;它必须精确引用现有 runnable leaf、带 reason,并在生成时归一化进 `EffectiveCommandRegistry`。Native identity annotation 若存在,应作为与 Registry 一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成 Agent metadata:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
```
|
||||
|
||||
4. 从统一 typed registry 生成最终 Catalog:
|
||||
1. 审核真实 Cobra 变化,确认命令和 flag 已实际存在。新增或修改稳定 command identity、primary CLI path 或 alias 现在通过 leaf Contract 的 `ContractFinal.Identity` 声明完成(identity collector 据此收集;reviewed `schema_command_registry/` 已退役)。参数、Skill 或 metadata 单独变化时不要机械改写 identity 声明,也不要从旧 Catalog 反向生成它。
|
||||
2. 不应进入稳定 Agent 契约的 public runnable leaf 使用 reviewed exclusions(exact path、带 reason)。Native identity annotation 若存在,应作为与 identity 声明一致的实现断言维护,而不是用来 materialize identity。
|
||||
3. 生成参数别名并验证运行时 Schema 组装。`go generate ./internal/cli` 只运行 `cmd_param_aliases`;`cmd_schema_catalog` 仅按需生成 CI/local dump。生产权威为 leaf `ContractFinal` / `ProductDecl`;CI dump 可经 `agent_metadata_inject.go` / `InstallBuildTimeAgentMetadataJSON` 在内存中注入 Agent metadata,不写 `schema_agent_metadata/`:
|
||||
|
||||
```bash
|
||||
make generate-schema
|
||||
go generate ./internal/cli
|
||||
# 可选:生成 artifacts/ 下的 CI/local dump
|
||||
make generate-schema-catalog
|
||||
```
|
||||
|
||||
也可以运行 `go generate ./internal/cli` 生成正常发布资产。生成文件包括:
|
||||
`cmd_schema_agent_metadata` 可保留为非交付工具/测试,但不是 `go:generate` 入口,也不应再作为发布步骤。
|
||||
|
||||
- `internal/cli/schema_agent_metadata/index.json`
|
||||
- `internal/cli/schema_agent_metadata/<product>.json`
|
||||
- `internal/cli/schema_agent_metadata_audit.json`
|
||||
- `internal/cli/schema_catalog.json`
|
||||
生成文件只有 `internal/cli/param_aliases_generated.go`(参数别名生成物)。`cmd_schema_catalog` 的 Catalog 和 meta-index 是可选 CI/local dump,不是交付物,且不得写入或提交到 `internal/cli/`。
|
||||
|
||||
只编辑来源;不要手工编辑 Agent metadata 或 Catalog 输出。
|
||||
`schema_agent_metadata/`、`schema_agent_metadata_audit.json` 与 `schema_hints/` 已退役;若存在则 policy 失败。只编辑来源;不要手工编辑或提交 Catalog / meta-index dump。
|
||||
|
||||
## 9. Completeness 与 final-delivery invariant
|
||||
|
||||
@@ -282,19 +289,19 @@ dws schema --all # 所有工具的完整 leaf 导
|
||||
|
||||
- 每个 public runnable Cobra leaf 要么能通过最终 embedded `SchemaIndex` 查询,要么有 exact、reviewed、带 reason 的 exclusion。
|
||||
- 每个最终 canonical path、primary CLI path 和 alias 都必须解析到同一个可执行 leaf;不得有 phantom path 或 collision。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex`、Agent metadata 和 Catalog canonical sets 必须精确一致,不能只比较 count。
|
||||
- `EffectiveCommandRegistry`、`SchemaRegistry/SchemaIndex` 与 Catalog canonical sets 必须精确一致(含组装时内存 inject 的 Agent metadata 语义),不能只比较 count。
|
||||
- Leaf payload、`--all` 中对应 tool 和 Catalog full tool 必须是同一个 resolved `ToolSpec` 的内容级等价投影,并通过 production loader round-trip。
|
||||
- overview/product/group summary 与 Catalog summary 必须等于同一个 `ToolSpec.ToSummaryPayload()`;alias 查询只允许 `cli_path` 和 `is_alias` 这两个视图字段变化。
|
||||
- 每个最终字段及 parameter field 的 provenance winner value 必须与 delivered value 精确一致;不能只验证 provenance source、count 或字段是否存在。
|
||||
- 每个 MCP `interface_ref` 必须在 pinned interface registry 精确存在;local/composite/unavailable 必须满足同一 conflict matrix。
|
||||
- `--all` 的 tool set 必须与最终 index 一对一,且每个工具包含完整参数契约。
|
||||
- 连续两次生成必须字节稳定,提交的生成物不得漂移。
|
||||
- **同源门禁(规划,见同源文档 §4)**:受管命令逐步满足 `HOM-P1`–`P3`(parameters ≡ cobra/Contract)、`HOM-S1`–`S3`(Safety/Risk 对齐)、`HOM-I1`(interface 不创建 flag)、`HOM-D1`(help ≡ schema parameters)。hints 不得作为 type/required/default 的 winner。
|
||||
|
||||
推荐本地验证:
|
||||
|
||||
```bash
|
||||
make generate-schema-agent-metadata
|
||||
make generate-schema-catalog
|
||||
make generate-schema
|
||||
./scripts/policy/check-generated-drift.sh
|
||||
./scripts/policy/check-schema-catalog.sh
|
||||
go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
@@ -303,9 +310,13 @@ go test ./internal/cli ./internal/app ./internal/generator/... -count=1
|
||||
## 10. 明确禁止
|
||||
|
||||
- 运行时调用 MCP `tools/list` 或访问网络生成 Schema。
|
||||
- 从旧 `schema_catalog.json` 或其他 generated JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 从 `schema_catalog/` 等生成 JSON 反向创建/补齐 Cobra leaf、flag、CommandRegistry 或下一轮 Catalog。
|
||||
- 重新引入 `schema_agent_metadata/`(或 audit JSON)作为交付物、`go:embed` 目标,或把它写回 `go:generate` 入口。
|
||||
- 从 MCP meta / 已退役的 `schema_mcp_metadata` **生成或补齐** LeafSpec/Shortcut 主路径的 CLI flag(interface overlay 除外);未满足同源文档 §5 准入条件时启用「MCP 透传生成通道」。
|
||||
- 把 native annotation、legacy registry 或 Catalog 当作 identity fallback;或在 `EffectiveCommandRegistry` 之后再次选择 identity winner。
|
||||
- renderer、query 或 gate 在 `SchemaRegistry` 之后重新读取 source 并做第二次 merge。
|
||||
- 用 prefix/wildcard exclusion 隐藏未来命令。
|
||||
- 让 manual hint、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 让 ProductDecl / leaf `Contract`、CommandRegistry 或 interface metadata 宣称一个不存在的命令、flag 或 RPC 可用。
|
||||
- 重新引入 `schema_hints/`(含 selection/metadata/imported/audit JSON)或任何 HintFile overlay,并在与 Contract/cobra 冲突时赢得 type/required/default。
|
||||
- 把 `schema --all` 当作普通业务数据查询,或把其完整结果无条件注入 Agent 上下文。
|
||||
- 将 LeafSpec、`+shortcut` 或 write-guard/cursor/多步命令注册为 `mcp_passthrough` 表面。
|
||||
|
||||
@@ -63,23 +63,24 @@
|
||||
|---|:---:|---|
|
||||
| covered-1to1 | 144 | lark 组合在钉钉塌缩成 1:1,封装层已覆盖 |
|
||||
| no-dingtalk-tool | 127 | 钉钉无对应工具,客观不可对齐 |
|
||||
| **gap-buildable** | **42** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`、task `+related-tasks` |
|
||||
| covered-smart | 48 | 已建智能 shortcut / 部分覆盖 |
|
||||
| **gap-buildable** | **41** | 钉钉有工具、值得补成智能 shortcut(**建设目标**);已建 minutes `+detail`/`+replace-batch`、base `+record-share-links`/`+resolve-base`、im `+thread-replies`/`+chat-messages`/`+chat-list`、task `+related-tasks` |
|
||||
| covered-smart | 49 | 已建智能 shortcut / 部分覆盖 |
|
||||
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 7 → 剩 42,按服务)
|
||||
## 🎯 gap-buildable 目标清单(原 49 条,已建 8 → 剩 41,按服务)
|
||||
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
> 已落地:minutes `+detail`(✅ smart `+detail`)、minutes `+word-replace`(✅ smart `+replace-batch`,批量+去重)、base `+record-share-link-create`(✅ smart `+record-share-links`,>20 去重+分片+合并)、im `+threads-messages-list`(✅ smart `chat +thread-replies`,list_topic_replies + 投影)、im `+chat-list`(✅ smart `chat +chat-list`)、task `+get-related-tasks`(✅ smart `todo +related-tasks`,三角色并集+去重+投影)。
|
||||
|
||||
### im → chat(6)
|
||||
### im → chat(7)
|
||||
|
||||
| lark 命令 | risk | 保真度差距(钉钉有 tool,缺什么智能) |
|
||||
|---|---|---|
|
||||
| `+chat-list` | read | dws 有 list-my-groups/list-all-conversations 原子 tool,但无 types 枚举+bot剥p2p降级、无 exclude-muted 客户端过滤、无字段投影 |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊 list_conversation_message_v2 / list_individual_chat_message 互斥 + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+chat-search` | read | dws 无群名模糊搜索v2对应 tool(search_common_groups/find 语义不同),缺 query规范化、mode映射、mute过滤、meta投影 |
|
||||
| `+chat-list` ✅ | read | **已建 smart `chat +chat-list`**:`list_all_conversations` + 默认仅群聊 + `--types group/p2p` + `--exclude-muted` + page-size/page-token 别名 + `--page-all/--page-limit` 数字 cursor 自动翻页、跨页去重、合并后类型过滤和完整性 ledger。剩余未做:sort/sort-type、bot 身份 p2p 剥离(DWS 无对应身份模型) |
|
||||
| `+chat-messages-list` ✅ | read | **已建 smart `chat +chat-messages`**:群/单聊互斥解析、时间范围、asc/desc、时间边界全量翻页、reaction、资源下载与完整性 ledger |
|
||||
| `+chat-search` ✅ | read | **已建 smart `chat +chat-search`**:真实 `search_groups` 关键词搜索 + page-size/page-token 别名 + `--page-all/--page-limit` 不透明 cursor 自动翻页、跨页去重和完整性 ledger。Lark v2 的 member/type/mode/manager/sort 过滤没有可验证的钉钉对应参数,未伪造 |
|
||||
| `+flag-list` ✅ | read | **已建 smart `chat +flag-list`**:真实 `list_message_favorites` 的 `items + hasMore + 数字 nextCursor`,支持 page-size/page-token、`--page-all/--page-limit`、跨页去重和完整性 ledger;仅对齐 message favorite,不模拟 Lark Feed thread flag |
|
||||
| `+messages-resources-download` | write | dws download-media 走 get_resource_download_url 拿URL,缺分片Range下载/重试/扩展名推断/安全落盘路径校验 |
|
||||
| `+messages-search` | read | dws 有 search_messages_by_keyword/by_time_range/by_sender/at_me 多个原子 tool,但各自单点,缺统一多维filter编排+mget+chat上下文富化+跨字段Validate |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:list_topic_replies + sender/text/time 投影。剩余未做:reactions 富化、资源下载 |
|
||||
| `+messages-search` ✅ | read | **已建 smart `chat +search-msg`**:统一多维过滤、精确时间范围、asc/desc、cursor 全量翻页、mget 富化、reaction、资源下载与完整性 ledger。剩余差异是 Lark chat 上下文和部分 sender/attachment 类型过滤 |
|
||||
| `+threads-messages-list` ✅ | read | **已建 smart `chat +thread-replies`**:支持主消息 ID 自动只读解析 conversation/thread,也支持显式 group + thread/topic ID;list_topic_replies + sender/text/time/reaction/resource 投影 + 下层毫秒级 nextCursor 有界自动翻页、跨页去重、完整性 ledger,以及全量结果 asc/desc。与 Lark 的剩余差异是钉钉底层没有服务端 asc 单页,因此 DWS 的 asc 明确要求 `--page-all`,避免伪全局排序 |
|
||||
|
||||
### task → todo(3)
|
||||
|
||||
@@ -176,7 +177,7 @@
|
||||
|
||||
## 已建智能 shortcut(covered-smart,48)— 可继续升级保真度
|
||||
|
||||
- **im**: +chat-members-list +messages-send +threads-messages-list
|
||||
- **im**: +chat-members-list +chat-list +messages-send +threads-messages-list
|
||||
- **task**: +complete +assign +get-my-tasks +get-related-tasks
|
||||
- **contact**: +search-user
|
||||
- **calendar**: +agenda +create +update +freebusy +suggestion
|
||||
|
||||
+1914
-180
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
# DWS Skill 内容框架合同
|
||||
|
||||
> 本分支权威合同:`skills/mono` / `skills/multi` 的**内容组织**与 zip 内容树形状。
|
||||
> 不做安装/升级行为约定。质检见 [skill-mono-multi-qa.md](skill-mono-multi-qa.md)。
|
||||
> 对齐调研:[skill-wukong-align-plan.md](skill-wukong-align-plan.md)。
|
||||
|
||||
## 1. 两棵内容树
|
||||
|
||||
| 树 | 路径 | 角色 |
|
||||
|---|---|---|
|
||||
| **mono**(单 skill) | `skills/mono/` | 单一 `SKILL.md` 入口 + `references/products/*` 产品面 + 全局协议 |
|
||||
| **multi**(多 skill) | `skills/multi/` | 平铺 `dingtalk-*` 产品 skill + 必选 `dingtalk-shared` |
|
||||
|
||||
Agent / 安装面选哪棵树由**行为分支**决定;本文件只规定树内合同。
|
||||
|
||||
## 2. Multi 目录合同(如何新增一个产品 skill)
|
||||
|
||||
新建 `skills/multi/<name>/` 时必须满足:
|
||||
|
||||
1. **命名**
|
||||
- 产品 skill:`dingtalk-<product>`(小写、连字符)
|
||||
- 共享 skill:仅允许 `dingtalk-shared`
|
||||
2. **根文件**
|
||||
- 必有 `SKILL.md`(YAML frontmatter + 正文)
|
||||
- `references/` 推荐;无 reference 的 skill(如极简 profile)须在质检 omit 表登记
|
||||
- `scripts/` 可选;脚本须被本 skill 树内某 `.md` 引用,或进入 orphan allowlist
|
||||
3. **Frontmatter 最小集**(产品 / shared)
|
||||
- `name`:与目录名一致
|
||||
- `description`:非空,含触发意图与边界
|
||||
- `metadata.category`:`product` 或 `shared`(允许历史写法把 `cli_version` 放在 frontmatter 顶层)
|
||||
- `metadata.requires.bins`:含 `dws`
|
||||
4. **契约块**
|
||||
- 产品 skill 推荐内嵌 `<!-- DWS_RUNTIME_CONTRACT_START -->…END -->` **或** 明确 PREREQUISITE 指向 `dingtalk-shared`
|
||||
- `dingtalk-shared` 承载跨产品路由与全局协议落点
|
||||
5. **与 mono 映射**
|
||||
- 每个 mono `references/products/<stem>`(文件或目录)必须在
|
||||
`skills/content-qa/mono-multi-coverage.yaml` 有 `coverage` 或 `omit_coverage` 行
|
||||
|
||||
### 2.1 推荐骨架
|
||||
|
||||
```text
|
||||
skills/multi/dingtalk-example/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── example.md # 主产品面
|
||||
│ └── … # 子章节 / 意图表
|
||||
└── scripts/ # 可选;须被 md 引用
|
||||
└── example_helper.py
|
||||
```
|
||||
|
||||
## 3. Mono 目录合同(质检对照基准)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/ # 覆盖质检主源
|
||||
│ ├── error-codes.md # 全局协议示例
|
||||
│ ├── error-codes.md
|
||||
│ └── …
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
- `references/products/` 下每个顶层 stem(`.md` 去后缀或子目录名)计入覆盖索引。
|
||||
- 同 stem 的 `.md` + 子目录视为同一产品面(如 `doc.md` + `doc/`)。
|
||||
|
||||
## 4. 共享内容(`dingtalk-shared`)
|
||||
|
||||
| 职责 | 落点 |
|
||||
|---|---|
|
||||
| 跨产品路由 / 工作流 | `references/routing.md`、`workflow-routing.md`、`intent-guide.md` |
|
||||
| 运行时最小契约长文 | `references/runtime-contract.md`(受 context-budget 约束) |
|
||||
| 全局协议(确认门禁 / Schema 教学等) | `references/`;见质检基线 |
|
||||
| 与 mono 全局文同名迁移 | `error-codes`、`url-patterns`、`capability-limits`、`channel-login`、`global-reference`、`recipes/`(`conventions.md`、`meta.md`、`lite-catalog.md`) |
|
||||
|
||||
产品专属规则(如 AI 表格 `field-rules`)允许下沉到对应 `dingtalk-*`,须在覆盖表注明。
|
||||
|
||||
## 5. Zip 内容布局合同(形状,非安装默认)
|
||||
|
||||
发布物 `dws-skills.zip`(及 embed 同源)内容树形状:
|
||||
|
||||
| Zip 路径 | 含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 内容副本(兼容旧面) |
|
||||
| `<root>/mono/` | 与 `skills/mono/` 同构 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言源树形状;**不**断言安装器默认解压哪棵。
|
||||
|
||||
## 6. 与悟空 `dingtalk-skills/` 对照(组织概念 only)
|
||||
|
||||
| 维度 | DWS `skills/multi` | 悟空 `dingtalk-skills/`(develop) |
|
||||
|---|---|---|
|
||||
| 布局 | flat `dingtalk-*` + `dingtalk-shared` | 同构 flat |
|
||||
| 集合 | 产品 skill + shared(含 event/profile/…;dev/skill 等长尾落在 misc) | 更小产品集(如 attendance/report 独立目录) |
|
||||
| 质检权威 | **mono 单 skill 树** | 不作为 DWS 覆盖基准 |
|
||||
| 不移植 | `_install.sh` / bundle / dual / Qwen overlay | — |
|
||||
|
||||
悟空独有命名(如 `dingtalk-attendance`)在 DWS 中由 `dingtalk-misc` 承接对应 mono `attendance*` / `report` / `oa` / `sheet` / `dev` 等面——见覆盖表。
|
||||
|
||||
## 7. 变更流程
|
||||
|
||||
1. 改 / 增内容 → 更新 `skills/content-qa/mono-multi-coverage.yaml`(coverage 或 omit)
|
||||
2. 跑 `make skill-mono-multi-content`(该独立门禁不包含在默认 `make policy` 中)
|
||||
3. 失败则修内容或更新 reviewed omit(disposition + 原因),**禁止**用安装默认值绕过
|
||||
@@ -0,0 +1,76 @@
|
||||
# Mono↔Multi Skill 内容质检规格
|
||||
|
||||
> 对照基准:`skills/mono`(单 skill)。被测主体:`skills/multi`。
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`。
|
||||
> 执行:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)。
|
||||
|
||||
## 1. 质检矩阵
|
||||
|
||||
| ID | 类型 | 输入 | 通过准则 |
|
||||
|---|---|---|---|
|
||||
| **G1 形状** | 结构 | `skills/multi/*` | 仅 `dingtalk-*`(含必选 `dingtalk-shared`);每目录有 `SKILL.md` |
|
||||
| **G2 结构** | 结构 | 各 `SKILL.md` frontmatter | `name`==目录名;非空 `description`;`category`∈{product,shared};`requires.bins` 含 `dws` |
|
||||
| **G3 覆盖** | 覆盖 | mono `references/products/*` 顶层 stem | 每 stem ∈ `coverage` 或 `omit_coverage`;coverage 目标 skill/refs 存在 |
|
||||
| **G4 漂移** | 漂移 | scripts、成对文件、全局协议 | orphan 脚本 ∈ allowlist;paired 内容一致(允许合同声明的布局链接替换);全局协议存在或 ∈ `omit_global` |
|
||||
| **G5 链接** | 可达性 | 合同覆盖的 paired Markdown | 内联相对链接目标文件或目录存在且不逃出仓库;外链、纯锚点和锚点内容不在检查范围 |
|
||||
|
||||
已有门禁(继续复用,不替代本矩阵):`check-skill-commands`、`check-skill-context-budget`、`check-multi-im-skill-chain`、`skill_docs_policy`、whiteboard 成对测试。
|
||||
|
||||
## 2. 有意省略 / 延期登记格式
|
||||
|
||||
YAML(见 coverage 文件):
|
||||
|
||||
```yaml
|
||||
omit_coverage:
|
||||
- mono: simple
|
||||
disposition: covered_by # covered_by | defer | wontfix
|
||||
via: dingtalk-misc # optional
|
||||
reason: "拆入 oa/devdoc…"
|
||||
|
||||
omit_global:
|
||||
- id: field-rules-global
|
||||
mono_path: references/field-rules.md
|
||||
expected_multi: dingtalk-aitable/references/field-rules.md
|
||||
disposition: covered_by
|
||||
reason: "AI 表格字段规则已下沉到 dingtalk-aitable;G3/coverage 不强制全局同名"
|
||||
|
||||
orphan_scripts_allowlist:
|
||||
- path: dingtalk-misc/scripts/report_received_today.py
|
||||
disposition: defer
|
||||
reason: "pending report.md reference"
|
||||
|
||||
paired_files:
|
||||
- mono: references/products/sheet.md
|
||||
multi: dingtalk-misc/references/sheet.md
|
||||
mode: link-normalized
|
||||
link_substitutions:
|
||||
- mono: "../url-patterns.md"
|
||||
multi: "../../dingtalk-shared/references/url-patterns.md"
|
||||
- mono: "../intent-guide.md"
|
||||
multi: "sheet-intent-guide.md"
|
||||
```
|
||||
|
||||
**处置原则**:质检失败 → 修**内容**或更新 reviewed omit;**不**改安装/升级默认。
|
||||
|
||||
## 3. 缺口基线(相对 mono)
|
||||
|
||||
| ID | 项 | disposition | 说明 |
|
||||
|---|---|---|---|
|
||||
| M1 | recovery-guide / RECOVERY_EVENT_ID 闭环 | **removed** | 已从 mono/multi skill 文档删除;不做移植 |
|
||||
| M2 | confirmation_required 全局协议 | **done** | `dingtalk-shared/references/confirmation.md` + SKILL 导航 |
|
||||
| M3 | Schema 渐进查询教学 | **done** | `dingtalk-shared/references/schema-usage.md` |
|
||||
| M4 | `report_inbox_today.py` | `defer` / orphan 侧 | 验证后迁 misc 或删 |
|
||||
| M5 | multi LICENSE/NOTICE | `defer` | 内容或打包注入 |
|
||||
| M6 | aiapp 路由 vs orphan 脚本 | **done(标明未产品化)** | mono 死链移除;`unsupported-scripts.md` |
|
||||
| X1 | yida/finance/aiapp orphan scripts | **done(登记)** | 由 unsupported-scripts 具名引用 |
|
||||
| X2 | chat 死链 `extract_media_id.py` | n/a | 现仅为反模式提及 |
|
||||
| X3 | routing → markdown 错路径 | **done** | 已指 `dingtalk-misc/references/markdown.md`;drive 尾链已修 |
|
||||
| X4 | event 缺 metadata | **done** | |
|
||||
| X5 | multi skill 横幅 /「优先 mono」文案 | **done** | 横幅已全部移除 |
|
||||
| X6 | SAFETY_PREAMBLE_INJECT 无注入器 | **done** | 标记已移除 |
|
||||
|
||||
产品面覆盖:见 YAML `coverage`——mono products 均有 multi 承接(misc 聚合 attendance/oa/sheet/…)。
|
||||
|
||||
## 4. 与悟空
|
||||
|
||||
借鉴 frontmatter / 断链 / requires 等**检查维度**;不运行悟空 bundle zip 校验脚本。覆盖权威始终是 DWS mono。
|
||||
@@ -0,0 +1,272 @@
|
||||
# DWS multi-skill **内容框架**对齐方案(相对 dws-wukong develop)
|
||||
|
||||
> 状态:**执行中** — Phase 1–3 已落地;M2/M3 已补;**M1 recovery 闭环已从 skill 删除(不做移植)**。
|
||||
> 合同短文:[skill-content-framework.md](skill-content-framework.md)
|
||||
> 质检规格:[skill-mono-multi-qa.md](skill-mono-multi-qa.md)
|
||||
> 机读合同:`skills/content-qa/mono-multi-coverage.yaml`
|
||||
> 门禁:`make skill-mono-multi-content`(独立门禁;默认 `make policy` 按设计不包含该检查)
|
||||
>
|
||||
> 撰写 / 收窄 / 质检增补 / 执行:2026-08-05
|
||||
> 工作树:`/Users/john/GolandProjects/open-source/dws-multi-skill-align`
|
||||
> 分支:`feat/multi-skill-framework-align`(自 `origin/main` @ `a37e6e68`)
|
||||
> **本分支范围:只做 skill 内容的这个框架**(目录布局、文档契约、共享内容约定、zip 内容树合同、**相对 mono 的内容质检**)。
|
||||
> **不做**安装/升级引擎、agent-home、脚本 skill-install 行为翻转。
|
||||
>
|
||||
> 对照仓:
|
||||
>
|
||||
> | 仓 | 路径 | 基线 |
|
||||
> |---|---|---|
|
||||
> | DWS OSS CLI(本工作树) | `dws-multi-skill-align` | `origin/main` |
|
||||
> | dws-wukong | `~/GolandProjects/open-source/dws-wukong` | `origin/develop` @ `ab76629a`(调研时) |
|
||||
> | 行为参考(**另一分支**) | `dws-skill-mode-migration` @ `402429ac`/`d5c8982c` | 安装默认 multi / upgrade 强制 multi —— **不在本分支排期** |
|
||||
> | 内容缺口留档(参考) | 同迁移分支 `docs/skill-capability-completion.md`(M1–M6 / X1 等) | **仅作质检目标线索**,非本分支权威 |
|
||||
|
||||
---
|
||||
|
||||
## 0. TL;DR
|
||||
|
||||
1. **本分支 = skill 内容框架 + 相对 mono 的内容质检**:固化 `skills/multi` 组织合同,并用 **mono 单 skill 布局作对照基准**做覆盖/结构/漂移门禁(文档 + CI 内容护栏)。
|
||||
2. **对齐悟空**:只取内容树组织概念;质检以 **DWS-native** 设计为主(已有 policy/测试可复用)。悟空 `validate-multiskill-bundle.py` 仅借鉴「frontmatter / 断链 / requires」类检查思路,**不**移植 bundle/安装校验。
|
||||
3. **安装/升级行为**与 `402429ac`/`d5c8982c` → **单独 follow-up 分支**,本方案只登记。
|
||||
4. 质检 **不改**默认安装哪棵树;只保证 multi 内容相对 mono **可解释、可覆盖、可回归**。
|
||||
|
||||
### 0.1 IN SCOPE
|
||||
|
||||
| 类别 | 包含 |
|
||||
|---|---|
|
||||
| 内容树结构 | `skills/mono/` 与 `skills/multi/<name>/` 目录合同 |
|
||||
| 单 skill 约定 | `SKILL.md` frontmatter / 契约块 / Golden Route;`references/`;可选 `scripts/` |
|
||||
| 共享内容 | `dingtalk-shared` 职责与被引用方式;与 mono 全局文映射(文档级) |
|
||||
| 命名与集合 | `dingtalk-*` + `dingtalk-shared`;相对悟空的共有/独有清单(文档) |
|
||||
| Zip **内容布局合同** | `mono/` / `multi/` / 根 mono 副本的内容含义与树形状;不改安装默认 |
|
||||
| **Mono↔multi 内容质检** | 覆盖、结构、漂移三类门禁;复用/扩展现有 policy 与测试;缺口修复属内容编辑(另批或同分支内容 Phase) |
|
||||
| 内容架构文档 | 本文件 + 可选短文(架构合同 + 质检矩阵) |
|
||||
|
||||
### 0.2 OUT OF SCOPE
|
||||
|
||||
| 类别 | 去向 |
|
||||
|---|---|
|
||||
| 安装默认 multi、upgrade always-multi | Follow-up 分支(`402429ac`/`d5c8982c`) |
|
||||
| `LocateSkillsRoot` / `skill_setup` / `paths.go` / `skillhome` / install 脚本行为 | 同上 |
|
||||
| 安装/运行时 manifest、state.json、mode 切换、telemetry header | 拒绝或行为分支 |
|
||||
| 悟空 `_install.sh` / dual / Qwen / RewindDesktop / pod | 拒绝 |
|
||||
| 非 skill 内容的 CLI 功能(schema/shortcut 代码等) | 拒绝 |
|
||||
| 把质检做成「改安装默认值」的后门 | 拒绝 |
|
||||
|
||||
---
|
||||
|
||||
## 1. 内容现状盘点
|
||||
|
||||
### 1.1 DWS `skills/mono`(质检对照基准 · 单 skill)
|
||||
|
||||
```text
|
||||
skills/mono/
|
||||
├── SKILL.md
|
||||
├── references/
|
||||
│ ├── products/<area>.md|…/ # 产品能力面(质检「覆盖」主源)
|
||||
│ ├── error-codes.md、… # 全局协议(无 recovery 闭环)
|
||||
│ └── best_practices/…
|
||||
└── scripts/
|
||||
```
|
||||
|
||||
### 1.2 DWS `skills/multi`(内容主体)
|
||||
|
||||
```text
|
||||
skills/multi/
|
||||
├── dingtalk-shared/ # 跨产品契约 / routing / 全局协议应落点
|
||||
└── dingtalk-*/ # 19 产品 + 各 references、scripts
|
||||
```
|
||||
|
||||
仅 DWS 有(悟空无):dev, event, hrbrain, markdown, pat, profile, skill。
|
||||
|
||||
### 1.3 悟空 `dingtalk-skills/`(内容组织对照,非质检权威)
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;单 skill 骨架同构。**不作为 mono 覆盖基准**(集合更小、不同源)。
|
||||
|
||||
### 1.4 Zip 内容布局合同
|
||||
|
||||
| Zip 路径 | 内容含义 |
|
||||
|---|---|
|
||||
| `<root>/` | mono 副本(兼容) |
|
||||
| `<root>/mono/` | 显式 mono 内容源 |
|
||||
| `<root>/multi/` | 与 `skills/multi/` 同构 |
|
||||
|
||||
质检可断言「源树形状」;**不**断言安装面默认选哪棵。
|
||||
|
||||
### 1.5 现有 DWS skill 内容质检资产(复用清单)
|
||||
|
||||
| 资产 | 作用 | 与 mono↔multi 质检关系 |
|
||||
|---|---|---|
|
||||
| `scripts/policy/check-skill-commands.sh` + `skill-command-check/` | Skill 文内 `dws …` 命令路径存在性 | **复用**(命令真实性);非覆盖映射 |
|
||||
| `scripts/policy/check-skill-context-budget.sh` | chat/event/mono/`dingtalk-shared` 上下文预算与冷启动约束 | **复用**(结构/预算);可扩展 shared 引用规则 |
|
||||
| `scripts/policy/check-multi-im-skill-chain.sh` + `multi-im-skill-chain/` | IM 意图单默认路由、retired scripts、handoff | **复用**(chat/event 链);面窄 |
|
||||
| `test/unit/skill_docs_policy_test.go` | 退役命令、event 扁平输出契约等 | **复用**;可加 mono↔multi 断言 |
|
||||
| `test/unit/whiteboard_skill_docs_test.go` | mono/multi whiteboard recipes **字节一致** | **样板**:产品面「同源文件」门禁范式 |
|
||||
| `test/skill_static`(`-tags skill_verify`) | 文内命令 vs Cobra;multi 查 flag | **复用**(opt-in 深度);非 CI 默认全量时可保持 tags |
|
||||
| `test/skill_e2e` / `test/run_skill_tests.py` | 执行层 / 用例驱动 | **偏行为**;本分支质检默认不依赖 e2e |
|
||||
| `Makefile` → `policy` 含 context-budget、multi-im-skill-chain;`skill-command-integrity` 独立 | 已有 CI 钩子 | 新门禁优先挂同类 policy / `test/unit` |
|
||||
|
||||
**缺口(尚无的门禁)**:系统的「mono `references/products/*` → multi 目录/文」覆盖表;frontmatter 全集完备性;orphan scripts。全局协议中 **确认门禁 / Schema 教学已补**;**recovery 闭环已从 skill 移除(不再作为缺口)**。
|
||||
|
||||
### 1.6 悟空侧类比质检
|
||||
|
||||
| 悟空 | 说明 | 本分支 |
|
||||
|---|---|---|
|
||||
| `scripts/validate-multiskill-bundle.py` | 校验 **已打好的 bundle zip**:frontmatter keys/category、`requires`、markdown 断链、scenario 编排 | **Adapt 思路** → DWS 源树(`skills/multi` + 对照 mono),不跑 zip 安装语义 |
|
||||
| `sync-monolith-to-multiskill.py` | mono→multi 派生 | **不**作默认质检手段;DWS 直接维护 multi |
|
||||
|
||||
结论:**DWS-native mono↔multi 质检**;悟空仅参考检查维度。
|
||||
|
||||
---
|
||||
|
||||
## 2. Diff(内容组织 + 质检视角)
|
||||
|
||||
### 2.1 已同构
|
||||
|
||||
Flat `dingtalk-*` + `dingtalk-shared`;`SKILL.md` + `references/`(+ 可选 `scripts/`)。
|
||||
|
||||
### 2.2 分叉与已知内容风险(质检要盯的)
|
||||
|
||||
| 风险 ID | 现象(线索) | 质检类型 |
|
||||
|---|---|---|
|
||||
| **C-cov** | mono `products/*` 能力面在 multi 无对应 skill/reference,或未登记「有意省略」 | 覆盖 |
|
||||
| **C-struct** | multi 缺 frontmatter 字段、`references/`、`DWS_RUNTIME_CONTRACT`、对 `dingtalk-shared` 引用不一致 | 结构 |
|
||||
| **C-drift-global** | 曾关注 recovery / 确认 / Schema;现确认与 Schema 已在 `dingtalk-shared`,**recovery skill 文档已删除** | 漂移(协议) |
|
||||
| **C-drift-orphan** | multi(或 mono)scripts/refs 无文档引用;或 routing 指向无索引产品(留档 X1/M6) | 漂移(孤儿) |
|
||||
| **C-pair** | 应对齐的成对文件(如 whiteboard recipes)内容不一致 | 漂移(成对) |
|
||||
|
||||
### 2.3 Reject
|
||||
|
||||
悟空安装包校验整文件照搬、内容集 19→12 砍产品、安装行为门禁冒充内容质检。
|
||||
|
||||
---
|
||||
|
||||
## 3. Goals / Non-goals
|
||||
|
||||
### 3.1 Goals
|
||||
|
||||
1. 固化 multi **内容目录合同**与 mono↔multi **映射说明**。
|
||||
2. 建立 **质检矩阵**(覆盖 / 结构 / 漂移)并以 mono 为对照基准;有意省略必须 reviewed 登记。
|
||||
3. **复用** §1.5 资产;新增门禁走 `scripts/policy` 或 `test/unit`,内容-only。
|
||||
4. (可选)纯内容元数据;**禁止**被安装引擎读取改行为。
|
||||
5. 质检失败 → 修 **内容**或更新「有意省略」表,不改 setup/upgrade。
|
||||
|
||||
### 3.2 Non-goals
|
||||
|
||||
安装/升级翻转;cherry-pick 行为提交;取消产品;悟空客户端;非 skill CLI 功能;用质检驱动默认 multi 安装。
|
||||
|
||||
---
|
||||
|
||||
## 4. 分期(内容框架 + 质检 · 均无安装引擎)
|
||||
|
||||
> 批准前 **零编码**(含不实现新 gates)。**已执行**:Phase 1–3 见文首状态。
|
||||
|
||||
### Phase 0 — 方案冻结(本文)
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 本文件;§7(含质检轨)勾选 |
|
||||
| **验收** | owner 重新批准 → ✅「现在开始执行」 |
|
||||
|
||||
### Phase 1 — Multi 内容目录合同 + 架构短文 ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | `skills/multi` 目录合同;与悟空内容树对照表;zip `multi/` 同构合同 |
|
||||
| **触达** | `docs/skill-content-framework.md` |
|
||||
| **验收** | 可指导「如何新增 dingtalk-* 内容目录」 |
|
||||
|
||||
### Phase 2 — Mono↔multi **内容质检规格**(矩阵 + 缺口基线) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | 质检规格 + 覆盖/omit 机读表 + 缺口 disposition |
|
||||
| **触达** | `docs/skill-mono-multi-qa.md`、`skills/content-qa/mono-multi-coverage.yaml` |
|
||||
| **验收** | 矩阵可人工抽查;缺口均有 disposition |
|
||||
|
||||
### Phase 3 — 质检落地:CI 内容护栏(复用 + 新 gate) ✅
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围** | G1–G4 自动门禁 |
|
||||
| **触达** | `test/unit/mono_multi_skill_content_test.go`、`scripts/policy/check-mono-multi-skill-content.sh`、`Makefile` |
|
||||
| **验收** | `make skill-mono-multi-content` 绿;已知缺口走 reviewed omit |
|
||||
|
||||
### Phase 4 — 可选:内容包元数据 + 缺口修复波次
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **范围 A** | 纯内容 layout/skill 列表元数据(人不读安装器) |
|
||||
| **范围 B** | 按 Phase 2 disposition **修内容**:确认 / Schema 已补;**recovery skill 文档已删除(wontfix 移植)**;orphan 脚本仍走 allowlist(M4 等) |
|
||||
| **验收** | 元数据不驱动安装;修复项关闭对应质检失败或转入 omit |
|
||||
|
||||
### 延期登记(非本分支)
|
||||
|
||||
| 主题 | 载体 |
|
||||
|---|---|
|
||||
| 默认 multi + upgrade always-multi | 行为分支 ← `402429ac`/`d5c8982c` |
|
||||
| skillhome / 安装面 bootstrap | 行为分支 |
|
||||
|
||||
---
|
||||
|
||||
## 5. Port / Adapt / Reject
|
||||
|
||||
| 项 | 决策 | 说明 |
|
||||
|---|---|---|
|
||||
| flat + `dingtalk-shared` 内容模型 | **Port** | 已有;合同 + 质检加固 |
|
||||
| 悟空 bundle frontmatter/断链/requires 检查维度 | **Adapt** | 做成 DWS 源树门禁,不校验 bundle zip/安装 |
|
||||
| whiteboard 式 mono/multi 成对一致 | **Port(范式)** | 推广到 reviewed 文件对 |
|
||||
| `validate-multiskill-bundle.py` 整脚本 | **Reject** | 绑定悟空 zip/Qwen 语义 |
|
||||
| `_install.sh` / dual / overlay | **Reject** | 非内容 |
|
||||
| 行为 cherry-pick | **Defer** | 另分支 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 与 `402429ac` / `d5c8982c`
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| 本分支 cherry-pick? | **否** |
|
||||
| 质检是否替代行为翻转? | **否** |
|
||||
| 行为分支 | 另开;可与内容/质检并行 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 批准清单(请重新勾选)
|
||||
|
||||
**范围**
|
||||
|
||||
- [x] 本分支 = skill **内容**框架 + **mono↔multi 内容质检**(§0.1);无安装/升级引擎
|
||||
- [x] `402429ac`/`d5c8982c` 及 setup/paths/install 脚本行为 **不在本分支**
|
||||
- [x] 取消产品与悟空客户端链路仍拒绝
|
||||
|
||||
**内容框架 Phase**
|
||||
|
||||
- [x] **Phase 1**:multi 目录合同 + 悟空内容树对照短文
|
||||
|
||||
**质检轨 Phase**
|
||||
|
||||
- [x] **Phase 2**:质检矩阵 + mono↔multi 覆盖/缺口基线规格(先文档,可执行)
|
||||
- [x] **Phase 3**:CI 内容护栏(G1–G4)—— 本迭代做 / 拆 PR / 只要规格暂不落地
|
||||
- [x] 质检失败处置原则:修内容或 reviewed omit,**不**改安装默认
|
||||
|
||||
**可选**
|
||||
|
||||
- [ ] **Phase 4A** 纯内容元数据:做 / 不做 / 以后
|
||||
- [x] **Phase 4B** recovery skill 文档 **removed/wontfix**;确认/Schema 已补;剩余 orphan(M4 等)仍 defer / allowlist
|
||||
|
||||
**Follow-up 知悉**
|
||||
|
||||
- [ ] 安装默认 multi + upgrade always-multi → **另一分支**
|
||||
|
||||
---
|
||||
|
||||
## 8. 下一步
|
||||
|
||||
**Phase 1–3 已落地**(合同短文 + 质检规格 + `skills/content-qa` + CI 门禁)。
|
||||
Phase 4B:recovery 已删除(不做移植);确认/Schema 已补。剩余 defer:orphan scripts(M4 等)、LICENSE/NOTICE(M5)、Phase 4A 元数据。
|
||||
安装默认 multi 等行为仍走 **另一分支**。
|
||||
|
||||
---
|
||||
|
||||
*锚点:`skills/mono`、`skills/multi`、§1.5 policy/测试、wukong `dingtalk-skills/`(组织对照 only)。*
|
||||
@@ -12,6 +12,7 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/gorilla/websocket v1.5.0
|
||||
github.com/itchyny/gojq v0.12.18
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/muesli/termenv v0.16.0
|
||||
github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.2-0.20260705041131-325e7c1049ad
|
||||
github.com/spf13/cobra v1.10.2
|
||||
@@ -40,7 +41,6 @@ require (
|
||||
github.com/itchyny/timefmt-go v0.1.7 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.13 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.19 // indirect
|
||||
github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
)
|
||||
|
||||
const (
|
||||
envDWSAgentHost = "DWS_AGENT_HOST"
|
||||
headerDWSAgentHost = "x-dws-agent-host"
|
||||
maxAgentHostBytes = 64
|
||||
)
|
||||
|
||||
var agentHostPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9_-]*$`)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: envDWSAgentHost,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用 DWS 的 Agent 运行形态标识;作为 x-dws-agent-host 发送供下游观测,本客户端不使用该值改变 PAT、鉴权或路由",
|
||||
Example: "cloud",
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentHost normalizes and validates the caller-declared runtime-form
|
||||
// signal. Only surrounding ASCII spaces and tabs are trimmed; other control
|
||||
// or Unicode whitespace remains visible to validation and is rejected. An
|
||||
// unset or ASCII-whitespace-only value means "do not emit".
|
||||
func parseAgentHost(raw string) (string, error) {
|
||||
if strings.ContainsAny(raw, "\r\n") {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
|
||||
value := strings.Trim(raw, " \t")
|
||||
if value == "" {
|
||||
return "", nil
|
||||
}
|
||||
if len(value) > maxAgentHostBytes {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
if !agentHostPattern.MatchString(value) {
|
||||
return "", invalidAgentHostError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func invalidAgentHostError() error {
|
||||
// Do not include the raw environment value in the error: it is an
|
||||
// untrusted caller-controlled string and may contain sensitive data.
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_HOST must be at most 64 bytes and match ^[a-z0-9][a-z0-9_-]*$",
|
||||
apperrors.WithReason("invalid_agent_host"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestParseAgentHost(t *testing.T) {
|
||||
valid := []struct {
|
||||
name string
|
||||
raw string
|
||||
want string
|
||||
}{
|
||||
{name: "unset", raw: "", want: ""},
|
||||
{name: "ASCII whitespace only", raw: " \t ", want: ""},
|
||||
{name: "cloud", raw: "cloud", want: "cloud"},
|
||||
{name: "desktop", raw: "desktop", want: "desktop"},
|
||||
{name: "legacy combined label remains valid", raw: "qwenwork_cloud", want: "qwenwork_cloud"},
|
||||
{name: "trim", raw: " \tcloud\t ", want: "cloud"},
|
||||
{name: "generic", raw: "host-2_alpha", want: "host-2_alpha"},
|
||||
{name: "leading digit", raw: "2nd_host", want: "2nd_host"},
|
||||
{name: "maximum length", raw: strings.Repeat("a", maxAgentHostBytes), want: strings.Repeat("a", maxAgentHostBytes)},
|
||||
}
|
||||
for _, tc := range valid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAgentHost() error = %v", err)
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Fatalf("parseAgentHost() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []struct {
|
||||
name string
|
||||
raw string
|
||||
}{
|
||||
{name: "carriage return", raw: "qwenwork_cloud\r"},
|
||||
{name: "line feed", raw: "\nqwenwork_cloud"},
|
||||
{name: "uppercase", raw: "Qwenwork_cloud"},
|
||||
{name: "internal space", raw: "qwenwork cloud"},
|
||||
{name: "internal tab", raw: "qwenwork\tcloud"},
|
||||
{name: "unicode", raw: "千问办公"},
|
||||
{name: "leading dash", raw: "-qwenwork"},
|
||||
{name: "leading underscore", raw: "_qwenwork"},
|
||||
{name: "control character", raw: "qwenwork\x00cloud"},
|
||||
{name: "vertical tab", raw: "\vcloud"},
|
||||
{name: "form feed", raw: "cloud\f"},
|
||||
{name: "next line", raw: "cloud\u0085"},
|
||||
{name: "non-breaking space", raw: "\u00a0cloud"},
|
||||
{name: "ideographic space", raw: "cloud\u3000"},
|
||||
{name: "too long", raw: strings.Repeat("a", maxAgentHostBytes+1)},
|
||||
}
|
||||
for _, tc := range invalid {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseAgentHost(tc.raw)
|
||||
if err == nil {
|
||||
t.Fatalf("parseAgentHost(%q) = %q, want error", tc.raw, got)
|
||||
}
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("parseAgentHost() error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("reason = %q, want invalid_agent_host", appErr.Reason)
|
||||
}
|
||||
if tc.raw != "" && strings.Contains(err.Error(), tc.raw) {
|
||||
t.Fatalf("error must not echo invalid value %q: %v", tc.raw, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersAddsAgentHostBeforeEditionMerge(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSAgentHost, " qwenwork_desktop ")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
t.Setenv(envDingtalkAgent, "agent-test")
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
mergeSawAgentHost := ""
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
mergeSawAgentHost = headers[headerDWSAgentHost]
|
||||
headers["claw-type"] = "test-claw"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := mergeSawAgentHost; got != "qwenwork_desktop" {
|
||||
t.Fatalf("MergeHeaders saw agent host %q, want qwenwork_desktop", got)
|
||||
}
|
||||
if got := headers[headerDWSAgentHost]; got != "qwenwork_desktop" {
|
||||
t.Fatalf("%s = %q, want qwenwork_desktop", headerDWSAgentHost, got)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dingtalk-dws-agent-code"]; got != "agent-code-test" {
|
||||
t.Fatalf("agentCode header = %q, want agent-code-test", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "test-claw" {
|
||||
t.Fatalf("claw-type = %q, want test-claw", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersOmitsAbsentOrInvalidAgentHost(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(envDWSChannel, "channel-test")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
for _, raw := range []string{"", " \t ", "DO_NOT_ECHO"} {
|
||||
t.Setenv(envDWSAgentHost, raw)
|
||||
headers := resolveIdentityHeaders()
|
||||
if _, ok := headers[headerDWSAgentHost]; ok {
|
||||
t.Fatalf("%s must be omitted for %q: %#v", headerDWSAgentHost, raw, headers)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
if got := headers["x-dws-channel"]; got != "channel-test" {
|
||||
t.Fatalf("channel header = %q, want channel-test", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentHostBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT_ECHO"
|
||||
t.Setenv(envDWSAgentHost, invalidValue)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
hookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("root command accepted invalid DWS_AGENT_HOST")
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_HOST validation")
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("root error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_host" {
|
||||
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("root error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/configmeta"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func init() {
|
||||
configmeta.Register(configmeta.ConfigItem{
|
||||
Name: agentproduct.EnvName,
|
||||
Category: configmeta.CategoryExternal,
|
||||
Description: "调用方声明的 Agent 产品标识;作为 x-dws-agent-product 发送并用于 IM 小尾巴,本客户端不使用该值改变 HTTP claw-type/PAT",
|
||||
DefaultValue: "未设置(请求头省略,IM 使用当前发行版默认值)",
|
||||
Example: "qwenwork",
|
||||
})
|
||||
}
|
||||
|
||||
// parseAgentProduct converts the reusable package error into the CLI's stable
|
||||
// structured validation error without exposing the untrusted raw value.
|
||||
func parseAgentProduct(raw string) (string, error) {
|
||||
value, err := agentproduct.Parse(raw)
|
||||
if err != nil {
|
||||
return "", invalidAgentProductError()
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func invalidAgentProductError() error {
|
||||
return apperrors.NewValidation(
|
||||
"DWS_AGENT_PRODUCT must be at most 64 bytes and match ^[A-Za-z0-9][A-Za-z0-9_-]*$",
|
||||
apperrors.WithReason("invalid_agent_product"),
|
||||
)
|
||||
}
|
||||
|
||||
// resolveEditionClawType resolves the fixed routing/PAT identity supplied by
|
||||
// the active edition. DWS_AGENT_PRODUCT is deliberately not consulted.
|
||||
func resolveEditionClawType(headers map[string]string) string {
|
||||
if value := headers["claw-type"]; value != "" {
|
||||
return value
|
||||
}
|
||||
return edition.DefaultOSSClawType
|
||||
}
|
||||
|
||||
// applyAgentProductHeader injects only a valid, non-empty caller-declared
|
||||
// product. Invalid values are omitted on library paths that bypass root
|
||||
// validation; normal CLI execution rejects them before network access.
|
||||
func applyAgentProductHeader(headers map[string]string) map[string]string {
|
||||
value, err := agentproduct.ResolveFromEnv("")
|
||||
if err != nil || value == "" {
|
||||
if headers != nil {
|
||||
delete(headers, agentproduct.HeaderName)
|
||||
}
|
||||
return headers
|
||||
}
|
||||
if headers == nil {
|
||||
headers = make(map[string]string)
|
||||
}
|
||||
headers[agentproduct.HeaderName] = value
|
||||
return headers
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestUnsetAgentProductOmitsHeaderAndKeepsOpenSourceClawType(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAgentProductReturnsStableValidationError(t *testing.T) {
|
||||
const invalidValue = "DO_NOT ECHO"
|
||||
|
||||
got, err := parseAgentProduct(invalidValue)
|
||||
if got != "" {
|
||||
t.Fatalf("parseAgentProduct() = %q, want empty", got)
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("parseAgentProduct() error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation {
|
||||
t.Fatalf("category = %q, want validation", appErr.Category)
|
||||
}
|
||||
if appErr.Reason != "invalid_agent_product" {
|
||||
t.Fatalf("reason = %q, want invalid_agent_product", appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersSeparatesAgentProductFromClawType(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "wukong"
|
||||
headers[agentproduct.HeaderName] = "merge-product-must-not-win"
|
||||
headers["x-edition-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "credential-must-not-win"
|
||||
headers[agentproduct.HeaderName] = "credential-product-must-not-win"
|
||||
headers["x-enterprise-header"] = "preserved"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Run("unset omits Product and keeps edition claw-type", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("valid Product is final without changing claw-type", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, " qwenwork ")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if got := headers["x-edition-header"]; got != "preserved" {
|
||||
t.Fatalf("edition header = %q, want preserved", got)
|
||||
}
|
||||
if got := headers["x-enterprise-header"]; got != "preserved" {
|
||||
t.Fatalf("enterprise header = %q, want preserved", got)
|
||||
}
|
||||
if got := headers["x-dingtalk-source"]; got != "github" {
|
||||
t.Fatalf("x-dingtalk-source = %q, want github", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid library input omits Product and keeps edition claw-type", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwen work")
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("invalid Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestApplyAgentProductHeader(t *testing.T) {
|
||||
t.Run("valid value allocates headers", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
headers := applyAgentProductHeader(nil)
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
value string
|
||||
}{
|
||||
{name: "empty value", value: ""},
|
||||
{name: "invalid value", value: "qwen work"},
|
||||
} {
|
||||
t.Run(tc.name+" removes inherited header", func(t *testing.T) {
|
||||
t.Setenv(agentproduct.EnvName, tc.value)
|
||||
headers := applyAgentProductHeader(map[string]string{
|
||||
agentproduct.HeaderName: "must-not-leak",
|
||||
"x-preserved": "yes",
|
||||
})
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("%s must be omitted", agentproduct.HeaderName)
|
||||
}
|
||||
if got := headers["x-preserved"]; got != "yes" {
|
||||
t.Fatalf("x-preserved = %q, want yes", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRejectsInvalidAgentProductBeforeEditionHook(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
const invalidValue = "DO_NOT ECHO"
|
||||
t.Setenv(agentproduct.EnvName, invalidValue)
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
AfterPersistentPreRun: func(_ *cobra.Command, _ []string) error {
|
||||
hookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
root := NewRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs([]string{"version"})
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("root command accepted invalid DWS_AGENT_PRODUCT")
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("edition AfterPersistentPreRun ran before DWS_AGENT_PRODUCT validation")
|
||||
}
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !errors.As(err, &appErr) {
|
||||
t.Fatalf("root error type = %T, want *errors.Error", err)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != "invalid_agent_product" {
|
||||
t.Fatalf("root error = category %q reason %q", appErr.Category, appErr.Reason)
|
||||
}
|
||||
if strings.Contains(err.Error(), invalidValue) {
|
||||
t.Fatalf("root error must not echo invalid value: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeDoesNotInvokeEnterpriseCredentialHeaders(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
hookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(headers map[string]string) map[string]string {
|
||||
hookCalled = true
|
||||
headers["claw-type"] = "enterprise-default"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
if hookCalled {
|
||||
t.Fatal("EnterpriseCredentialHeaders hook ran during PAT error serialization")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentProductControlsObservabilityHeaderAndMessageClawTypeOnly(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
ClawTypeValue: "message-brand",
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("HTTP %s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("HTTP claw-type = %q, want wukong", got)
|
||||
}
|
||||
if got := edition.ClawType(); got != "qwenwork" {
|
||||
t.Fatalf("message clawType = %q, want qwenwork", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersRestoresIdentityAfterNilCredentialHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
|
||||
credentialHookCalled := false
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
EnterpriseCredentialHeaders: func(map[string]string) map[string]string {
|
||||
credentialHookCalled = true
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if !credentialHookCalled {
|
||||
t.Fatal("EnterpriseCredentialHeaders hook was not called")
|
||||
}
|
||||
if got := headers[agentproduct.HeaderName]; got != "qwenwork" {
|
||||
t.Fatalf("%s = %q, want qwenwork", agentproduct.HeaderName, got)
|
||||
}
|
||||
if got := headers["claw-type"]; got != "wukong" {
|
||||
t.Fatalf("claw-type = %q, want wukong", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveIdentityHeadersRestoresDefaultsAfterNilMergeHeaders(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(map[string]string) map[string]string {
|
||||
return nil
|
||||
},
|
||||
})
|
||||
|
||||
headers := resolveIdentityHeaders()
|
||||
if got := headers["claw-type"]; got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("claw-type = %q, want %q", got, edition.DefaultOSSClawType)
|
||||
}
|
||||
if _, ok := headers[agentproduct.HeaderName]; ok {
|
||||
t.Fatalf("unset Product must omit %s", agentproduct.HeaderName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveClawTypeIgnoresAgentProduct(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
edition.Override(&edition.Hooks{
|
||||
MergeHeaders: func(headers map[string]string) map[string]string {
|
||||
headers["claw-type"] = "wukong"
|
||||
return headers
|
||||
},
|
||||
})
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "qwenwork")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent-code")
|
||||
if got := apperrors.HostControlBlock()["clawType"]; got != "wukong" {
|
||||
t.Fatalf("hostControl.clawType = %q, want wukong", got)
|
||||
}
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() = %q, want wukong", got)
|
||||
}
|
||||
|
||||
t.Setenv(agentproduct.EnvName, "invalid product")
|
||||
if got := effectiveClawType(); got != "wukong" {
|
||||
t.Fatalf("effectiveClawType() with invalid env = %q, want wukong", got)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/agentproduct"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -334,18 +335,13 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
edition.Override(&edition.Hooks{ConfigDir: func() string { return "" }})
|
||||
captureRuntimeFailure(executor.Invocation{}, nil, nil)
|
||||
captureRuntimeFailure(executor.Invocation{}, errors.New("raw"), nil)
|
||||
oldArgs := os.Args
|
||||
os.Args = []string{"dws", "doc", "download", "--node", "n"}
|
||||
if got := runtimeCommandPath(executor.Invocation{}); len(got) != 2 {
|
||||
t.Fatalf("runtime command path = %#v", got)
|
||||
}
|
||||
os.Args = oldArgs
|
||||
|
||||
t.Setenv(authpkg.AgentCodeEnv, "")
|
||||
if hostControlProviderFromEnv() != "" {
|
||||
t.Fatal("host control enabled without agent code")
|
||||
}
|
||||
t.Setenv(authpkg.AgentCodeEnv, "agent")
|
||||
t.Setenv(agentproduct.EnvName, "")
|
||||
edition.Override(&edition.Hooks{MergeHeaders: func(headers map[string]string) map[string]string { return headers }})
|
||||
if got := hostControlProviderFromEnv(); got != edition.DefaultOSSClawType {
|
||||
t.Fatalf("default claw type = %q", got)
|
||||
@@ -358,6 +354,10 @@ func TestCrossPlatformCoverageOverlayRecoveryHostAndHelperRemainingCoverage(t *t
|
||||
|
||||
func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.T) {
|
||||
for _, command := range []*cobra.Command{newConfigCommand(), newCacheCommand()} {
|
||||
command.SetOut(io.Discard)
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.AddCommand(command)
|
||||
command.SetOut(io.Discard)
|
||||
if err := command.RunE(command, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -387,18 +387,18 @@ func TestCrossPlatformCoverageConfigAndCacheCommandRemainingCoverage(t *testing.
|
||||
for _, format := range []string{"json", "pretty", "table"} {
|
||||
_ = cacheRoot.PersistentFlags().Set("format", format)
|
||||
cacheCmd.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
fail := errors.New("write")
|
||||
cacheCmd.SetOut(appFailWriter{err: fail})
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "pretty")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("pretty write error = %v", err)
|
||||
}
|
||||
_ = cacheRoot.PersistentFlags().Set("format", "table")
|
||||
if err := printCacheCompatNotice(cacheCmd, "status"); !errors.Is(err, fail) {
|
||||
if err := printCacheCompatNotice(cacheCmd, "dws cache status"); !errors.Is(err, fail) {
|
||||
t.Fatalf("table write error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,7 +140,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
products := map[string]bool{}
|
||||
aliases := map[string]string{}
|
||||
tools := map[string]string{}
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{CLI: mcptypes.CLIOverlay{Skip: true}}, endpoints, products, aliases, tools, false)
|
||||
registerDynamicServer(mcptypes.ServerDescriptor{
|
||||
Endpoint: "https://server.test",
|
||||
CLI: mcptypes.CLIOverlay{
|
||||
@@ -148,7 +148,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
Tools: []mcptypes.CLITool{{Name: "tool"}, {Name: " "}},
|
||||
ToolOverrides: map[string]mcptypes.CLIToolOverride{"override": {}, " ": {}},
|
||||
},
|
||||
}, endpoints, products, aliases, tools)
|
||||
}, endpoints, products, aliases, tools, false)
|
||||
if endpoints["command"] == "" || aliases["alias"] != "id" || tools["override"] == "" {
|
||||
t.Fatalf("registered dynamic server = %#v %#v %#v", endpoints, aliases, tools)
|
||||
}
|
||||
@@ -200,7 +200,7 @@ func TestCrossPlatformCoverageDirectRuntimeRemainingCoverage(t *testing.T) {
|
||||
},
|
||||
},
|
||||
})
|
||||
if got, ok := directRuntimeToolEndpoint("append-override"); !ok || got != "https://append.test" {
|
||||
if got, ok := directRuntimeEndpoint("", "append-override"); !ok || got != "https://append.test" {
|
||||
t.Fatalf("append override endpoint = %q, %v", got, ok)
|
||||
}
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func TestCrossPlatformCoverageEmbeddedSkillAndTinyCommandsRemainingCoverage(t *t
|
||||
if err := completion.RunE(completion, []string{"other"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog := newCatalogCommand(nil)
|
||||
catalog := newCatalogCommand()
|
||||
catalog.SetOut(io.Discard)
|
||||
if err := catalog.RunE(catalog, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -12,7 +12,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/audit"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
@@ -26,6 +29,20 @@ var (
|
||||
)
|
||||
|
||||
func newAuditCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/audit.json
|
||||
// products.audit). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "audit",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "查看、导出和校验本地操作审计日志",
|
||||
UseWhen: []string{
|
||||
"需要排查本机 CLI 操作审计记录,或验证审计文件完整性",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查钉钉业务数据或发消息请用对应产品命令,不要用 audit",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "audit",
|
||||
Short: "操作审计日志管理",
|
||||
@@ -64,6 +81,39 @@ func newAuditTailCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&n, "lines", "n", 20, "显示最近 N 条记录")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "tail",
|
||||
CanonicalPath: "audit.tail",
|
||||
CLIPath: "audit tail",
|
||||
PrimaryCLIPath: "audit tail",
|
||||
},
|
||||
Description: "查看本地操作审计日志最近 N 条记录",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取本地审计日志尾部,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看本地操作审计日志最近 N 条记录",
|
||||
UseWhen: []string{"需要快速查看最近写入的审计记录(默认最近 20 条)"},
|
||||
AvoidWhen: []string{
|
||||
"需要按日期范围整段导出用 audit export",
|
||||
"需要校验哈希链用 audit verify",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws audit tail",
|
||||
"dws audit tail --lines 50",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -99,6 +149,39 @@ func newAuditExportCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&since, "since", "", "起始日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&until, "until", "", "截止日期 (YYYY-MM-DD)")
|
||||
cmd.Flags().StringVar(&format, "format", "jsonl", "输出格式: jsonl 或 csv")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "export",
|
||||
CanonicalPath: "audit.export",
|
||||
CLIPath: "audit export",
|
||||
PrimaryCLIPath: "audit export",
|
||||
},
|
||||
Description: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取并导出本地审计日志文件,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按日期范围导出本地操作审计日志(jsonl 或 csv)",
|
||||
UseWhen: []string{"需要把本地审计日志导出为 jsonl/csv,或按 --since/--until 取一段时间"},
|
||||
AvoidWhen: []string{
|
||||
"只看最近几条用 audit tail",
|
||||
"只校验哈希链完整性用 audit verify",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws audit export --format jsonl",
|
||||
"dws audit export --since 2026-07-01 --until 2026-07-14 --format csv",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -152,6 +235,36 @@ func newAuditVerifyCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&file, "file", "", "指定审计文件路径(默认最新文件)")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "audit",
|
||||
Name: "verify",
|
||||
CanonicalPath: "audit.verify",
|
||||
CLIPath: "audit verify",
|
||||
PrimaryCLIPath: "audit verify",
|
||||
},
|
||||
Description: "校验本地审计日志文件的哈希链完整性",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令校验本地审计日志哈希链,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "校验本地审计日志文件的哈希链完整性",
|
||||
UseWhen: []string{"怀疑审计文件被篡改,或需要确认最新/指定文件哈希链是否完整"},
|
||||
AvoidWhen: []string{"只浏览或导出日志内容时用 audit tail / audit export"},
|
||||
Examples: []string{
|
||||
"dws audit verify",
|
||||
"dws audit verify --file /path/to/audit.jsonl",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
@@ -199,6 +199,14 @@ func TestCrossPlatformCoverageAuditRuntimeCoverage(t *testing.T) {
|
||||
sharedAuditSink = previousSink
|
||||
loadTokenForProfile = previousLoader
|
||||
auditSinkOnce, auditCloseOnce = sync.Once{}, sync.Once{}
|
||||
// The process-wide sink was initialized by TestMain. Preserve that
|
||||
// initialized state when restoring it: leaving auditSinkOnce unused
|
||||
// lets a later runner overwrite the live sink without closing its
|
||||
// .audit.lock handle, which makes TestMain cleanup fail on Windows.
|
||||
auditSinkOnce.Do(func() {})
|
||||
if got := setupAuditSink(); got != previousSink {
|
||||
t.Errorf("restored audit sink = %T, want original %T", got, previousSink)
|
||||
}
|
||||
resetAuditIdentityCache()
|
||||
})
|
||||
|
||||
|
||||
+342
-44
@@ -40,12 +40,14 @@ import (
|
||||
)
|
||||
|
||||
type authLoginConfig struct {
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
Token string
|
||||
Force bool
|
||||
Device bool
|
||||
Recommend bool
|
||||
Yes bool
|
||||
TargetCorpID string
|
||||
HistoryProfileSelector string
|
||||
HistoryProfileSelectorExplicit bool
|
||||
}
|
||||
|
||||
type authLoginGuideAction string
|
||||
@@ -148,7 +150,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.Output = cmd.ErrOrStderr()
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
Explicit: cfg.HistoryProfileSelectorExplicit,
|
||||
})
|
||||
}
|
||||
tokenData, err = authDeviceLogin(provider, loginCtx)
|
||||
if err != nil {
|
||||
@@ -163,7 +168,10 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
provider.NoBrowser, _ = cmd.Flags().GetBool("no-browser")
|
||||
provider.TargetCorpID = cfg.TargetCorpID
|
||||
provider.IdentityEnricher = func(ctx context.Context, data *authpkg.TokenData) error {
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data)
|
||||
return enrichAuthLoginProfileFromContact(ctx, configDir, patCaller, data, authLoginHistoryHint{
|
||||
Selector: cfg.HistoryProfileSelector,
|
||||
Explicit: cfg.HistoryProfileSelectorExplicit,
|
||||
})
|
||||
}
|
||||
configureOAuthProviderCompatibility(provider, configDir)
|
||||
tokenData, err = authOAuthLogin(provider, loginCtx, authLoginForcesAuthorization(cfg))
|
||||
@@ -175,11 +183,23 @@ func newAuthLoginCommand(patCaller edition.ToolCaller) *cobra.Command {
|
||||
ResetRuntimeTokenCache()
|
||||
clearCompatCache()
|
||||
w := cmd.OutOrStdout()
|
||||
postLoginSelector := authpkg.TokenProfileSelector(tokenData)
|
||||
if tokenData != nil && strings.TrimSpace(tokenData.CorpID) != "" && strings.TrimSpace(tokenData.UserID) == "" {
|
||||
if profiles, loadErr := authLoadProfiles(configDir); loadErr == nil && profiles != nil {
|
||||
for i := range profiles.Profiles {
|
||||
profile := profiles.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(tokenData.CorpID) && strings.TrimSpace(profile.UserID) == "" {
|
||||
postLoginSelector = profileCLISelector(profile, profiles)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
runPostLoginAuthorization := func() error {
|
||||
if !recommendAuthMode {
|
||||
return nil
|
||||
}
|
||||
restoreProfile := replaceRuntimeProfile(authpkg.TokenProfileSelector(tokenData))
|
||||
restoreProfile := replaceRuntimeProfile(postLoginSelector)
|
||||
defer restoreProfile()
|
||||
recommendScopeMode := pat.LoginRecommendScopeRecommended
|
||||
var initialPlan *pat.LoginRecommendPlan
|
||||
@@ -287,7 +307,7 @@ var (
|
||||
migrateKeychainToFileDEK = authpkg.MigrateKeychainToFileDEK
|
||||
authMigrateTarget = func(cmd *cobra.Command) (string, error) { return cmd.Flags().GetString("to") }
|
||||
authRunForm = (*huh.Form).Run
|
||||
authSaveTokenData = authpkg.SaveTokenData
|
||||
authSaveTokenData = authpkg.SaveLoginTokenData
|
||||
authSaveAppConfig = authpkg.SaveAppConfig
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||
return provider.Login(ctx)
|
||||
@@ -494,7 +514,9 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
if selected == nil {
|
||||
return apperrors.NewValidation(fmt.Sprintf("profile %q not found", profileSelector))
|
||||
}
|
||||
profileSelector = authpkg.ProfileSelector(*selected)
|
||||
if strings.TrimSpace(selected.UserID) != "" {
|
||||
profileSelector = authpkg.ProfileSelector(*selected)
|
||||
}
|
||||
}
|
||||
restoreProfile := pushRuntimeProfile(profileSelector)
|
||||
defer restoreProfile()
|
||||
@@ -522,7 +544,11 @@ func newAuthStatusCommand() *cobra.Command {
|
||||
_ = authDeleteTokenData(configDir)
|
||||
} else if tokenData != nil {
|
||||
refreshFailure = refreshErr
|
||||
_ = authMarkProfileStatus(configDir, authpkg.TokenProfileSelector(tokenData), authpkg.ProfileStatusExpired)
|
||||
markSelector := profileSelector
|
||||
if markSelector == "" {
|
||||
markSelector = authpkg.StableTokenProfileSelector(configDir, tokenData)
|
||||
}
|
||||
_ = authMarkProfileStatus(configDir, markSelector, authpkg.ProfileStatusExpired)
|
||||
}
|
||||
}
|
||||
if refreshFailure == nil && authStatusAuthenticated(tokenData) {
|
||||
@@ -652,7 +678,14 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
||||
}
|
||||
stableSelector := selected.CorpID
|
||||
if exact {
|
||||
stableSelector = authpkg.ProfileSelector(*selected)
|
||||
if strings.TrimSpace(selected.UserID) == "" {
|
||||
// A blank historical profile can coexist with exact accounts in the
|
||||
// same organization. Preserve the exact local-name selector; reducing
|
||||
// it to corpId would log out the entire organization.
|
||||
stableSelector = strings.TrimSpace(selector)
|
||||
} else {
|
||||
stableSelector = authpkg.ProfileSelector(*selected)
|
||||
}
|
||||
if data, loadErr := authLoadTokenForProfile(configDir, stableSelector); loadErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
@@ -661,7 +694,7 @@ func logoutOneProfile(_ *cobra.Command, ctx context.Context, configDir, selector
|
||||
if profile.CorpID != selected.CorpID {
|
||||
continue
|
||||
}
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
@@ -687,7 +720,7 @@ func logoutAllProfiles(_ *cobra.Command, ctx context.Context, configDir string)
|
||||
_ = authRevokeToken(ctx)
|
||||
} else {
|
||||
for _, profile := range cfg.Profiles {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, authpkg.ProfileSelector(profile)); tokenErr == nil {
|
||||
if data, tokenErr := authLoadTokenForProfile(configDir, profileCLISelector(profile, cfg)); tokenErr == nil {
|
||||
_ = authRevokeTokenForData(ctx, data)
|
||||
}
|
||||
}
|
||||
@@ -1206,7 +1239,7 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
yes, _ = cmd.Root().PersistentFlags().GetBool("yes")
|
||||
profileSelector, _ = cmd.Root().PersistentFlags().GetString("profile")
|
||||
}
|
||||
targetCorpID, err := resolveAuthLoginTargetCorpID(defaultConfigDir(), profileSelector)
|
||||
targetCorpID, historyProfileSelector, historyProfileSelectorExplicit, err := resolveAuthLoginTarget(defaultConfigDir(), profileSelector)
|
||||
if err != nil {
|
||||
return authLoginConfig{}, err
|
||||
}
|
||||
@@ -1221,15 +1254,18 @@ func resolveAuthLoginConfig(cmd *cobra.Command) (authLoginConfig, error) {
|
||||
"flow", flow,
|
||||
"profile_selector", strings.TrimSpace(profileSelector),
|
||||
"target_corp_id", targetCorpID,
|
||||
"history_profile_selector", historyProfileSelector,
|
||||
"recommend", recommend,
|
||||
)
|
||||
return authLoginConfig{
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
Token: strings.TrimSpace(token),
|
||||
Force: force,
|
||||
Device: device,
|
||||
Recommend: recommend,
|
||||
Yes: yes,
|
||||
TargetCorpID: targetCorpID,
|
||||
HistoryProfileSelector: historyProfileSelector,
|
||||
HistoryProfileSelectorExplicit: historyProfileSelectorExplicit,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1238,17 +1274,57 @@ func authLoginForcesAuthorization(_ authLoginConfig) bool {
|
||||
}
|
||||
|
||||
func resolveAuthLoginTargetCorpID(configDir, selector string) (string, error) {
|
||||
targetCorpID, _, _, err := resolveAuthLoginTarget(configDir, selector)
|
||||
return targetCorpID, err
|
||||
}
|
||||
|
||||
// resolveAuthLoginTarget keeps the authorization target separate from the
|
||||
// local identity hint used only when contact cannot resolve the logged-in
|
||||
// account. An implicit current profile must never constrain a fresh OAuth
|
||||
// authorization to that profile's organization.
|
||||
func resolveAuthLoginTarget(configDir, selector string) (targetCorpID, historySelector string, explicit bool, err error) {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return "", nil
|
||||
if profile, resolveErr := authResolveProfile(configDir, ""); resolveErr == nil && profile != nil {
|
||||
return "", authLoginHistorySelector(configDir, profile), false, nil
|
||||
}
|
||||
return "", "", false, nil
|
||||
}
|
||||
if profile, err := authResolveProfile(configDir, selector); err == nil && profile != nil {
|
||||
return strings.TrimSpace(profile.CorpID), nil
|
||||
historySelector := authLoginHistorySelector(configDir, profile)
|
||||
_, _, identityExact := authpkg.ParseIdentitySelector(selector)
|
||||
if selector != strings.TrimSpace(profile.CorpID) && selector != strings.TrimSpace(profile.CorpName) {
|
||||
identityExact = true
|
||||
}
|
||||
return strings.TrimSpace(profile.CorpID), historySelector, identityExact, nil
|
||||
}
|
||||
if _, _, exact := authpkg.ParseIdentitySelector(selector); exact || strings.Contains(selector, ":") {
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
if strings.HasPrefix(selector, "ding") {
|
||||
return selector, nil
|
||||
// A known organization that failed resolution is ambiguous (for
|
||||
// example, two local accounts without an org-current pointer), not a
|
||||
// request to invent a new corpId.
|
||||
if cfg, loadErr := authLoadProfiles(configDir); loadErr == nil && cfg != nil {
|
||||
for i := range cfg.Profiles {
|
||||
if strings.TrimSpace(cfg.Profiles[i].CorpID) == selector {
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q is ambiguous; use an exact corpId:userId selector", selector))
|
||||
}
|
||||
}
|
||||
}
|
||||
return selector, "", false, nil
|
||||
}
|
||||
return "", apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
return "", "", true, apperrors.NewValidation(fmt.Sprintf("profile %q not found", selector))
|
||||
}
|
||||
|
||||
func authLoginHistorySelector(configDir string, profile *authpkg.Profile) string {
|
||||
if profile == nil {
|
||||
return ""
|
||||
}
|
||||
if cfg, err := authLoadProfiles(configDir); err == nil && cfg != nil {
|
||||
return authpkg.ProfileSelectionSelector(*profile, cfg)
|
||||
}
|
||||
return authpkg.ProfileSelector(*profile)
|
||||
}
|
||||
|
||||
type contactProfileIdentity struct {
|
||||
@@ -1258,12 +1334,23 @@ type contactProfileIdentity struct {
|
||||
UserName string
|
||||
}
|
||||
|
||||
type authLoginHistoryHint struct {
|
||||
Selector string
|
||||
Explicit bool
|
||||
}
|
||||
|
||||
type tokenOverrideToolCaller interface {
|
||||
CallToolWithToken(ctx context.Context, token, productID, toolName string, args map[string]any) (*edition.ToolResult, error)
|
||||
}
|
||||
|
||||
func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edition.ToolCaller, data *authpkg.TokenData) error {
|
||||
if caller == nil || data == nil {
|
||||
func enrichAuthLoginProfileFromContact(
|
||||
ctx context.Context,
|
||||
configDir string,
|
||||
caller edition.ToolCaller,
|
||||
data *authpkg.TokenData,
|
||||
hints ...authLoginHistoryHint,
|
||||
) error {
|
||||
if data == nil {
|
||||
return nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
@@ -1288,6 +1375,27 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
)
|
||||
return nil
|
||||
}
|
||||
hint := authLoginHistoryHint{}
|
||||
if len(hints) > 0 {
|
||||
hint = hints[0]
|
||||
}
|
||||
tryHistory := func() bool {
|
||||
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, data, hint)
|
||||
if historyErr != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.history.error",
|
||||
"corp_id", corpID,
|
||||
"error", historyErr,
|
||||
)
|
||||
}
|
||||
return reused
|
||||
}
|
||||
if caller == nil {
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
var (
|
||||
result *edition.ToolResult
|
||||
@@ -1297,7 +1405,7 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
result, err = tokenCaller.CallToolWithToken(ctx, data.AccessToken, "contact", "get_current_user_profile", nil)
|
||||
} else {
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
return fmt.Errorf("login identity lookup requires an in-memory token override")
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1311,11 +1419,15 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
if strings.TrimSpace(data.UserID) != "" {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
tryHistory()
|
||||
return nil
|
||||
}
|
||||
identity, ok := contactProfileIdentityFromToolResult(result)
|
||||
identity, ok := contactProfileIdentityFromToolResult(result, corpID)
|
||||
if !ok {
|
||||
logging.AuthDebug("auth.login.identity.lookup.empty", "corp_id", corpID)
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
logging.AuthDebug(
|
||||
@@ -1327,19 +1439,42 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
"corp_name", strings.TrimSpace(identity.CorpName),
|
||||
)
|
||||
if identity.CorpID != "" && identity.CorpID != corpID {
|
||||
return fmt.Errorf("contact profile corpId %q does not match login corpId %q", identity.CorpID, corpID)
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.lookup.mismatch",
|
||||
"login_corp_id", corpID,
|
||||
"contact_corp_id", strings.TrimSpace(identity.CorpID),
|
||||
)
|
||||
if strings.TrimSpace(data.UserID) == "" {
|
||||
tryHistory()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
updated := *data
|
||||
exchangeUserID := strings.TrimSpace(data.UserID)
|
||||
if identity.CorpName != "" {
|
||||
updated.CorpName = identity.CorpName
|
||||
}
|
||||
if identity.UserID != "" {
|
||||
if exchangeUserID == "" && identity.UserID != "" {
|
||||
updated.UserID = identity.UserID
|
||||
}
|
||||
if identity.UserName != "" {
|
||||
if identity.UserName != "" && (exchangeUserID == "" || identity.UserID == "" || identity.UserID == exchangeUserID) {
|
||||
updated.UserName = identity.UserName
|
||||
}
|
||||
if strings.TrimSpace(updated.UserID) == "" {
|
||||
reused, historyErr := enrichAuthLoginProfileFromHistory(configDir, &updated, hint)
|
||||
if historyErr != nil {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.history.error",
|
||||
"corp_id", corpID,
|
||||
"error", historyErr,
|
||||
)
|
||||
}
|
||||
if reused {
|
||||
*data = updated
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if updated.CorpName == data.CorpName && updated.UserID == data.UserID && updated.UserName == data.UserName {
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
@@ -1361,7 +1496,144 @@ func enrichAuthLoginProfileFromContact(ctx context.Context, _ string, caller edi
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactProfileIdentity, bool) {
|
||||
// enrichAuthLoginProfileFromHistory recovers display metadata when the contact
|
||||
// service cannot describe an external-worker account. Historical profile
|
||||
// selection is never proof of the user who completed a fresh authorization:
|
||||
// only the token exchange or contact service may supply UserID.
|
||||
//
|
||||
// An explicit profile remains useful as a storage/selection hint. Keeping it in
|
||||
// LegacyOrgScopedProfile prevents the login from switching the process-global
|
||||
// current profile while SaveTokenData publishes the UID-less credential to the
|
||||
// unresolved organization slot. A historical blank profile is updated in
|
||||
// place; an exact historical profile and its token remain untouched.
|
||||
func enrichAuthLoginProfileFromHistory(configDir string, data *authpkg.TokenData, hints ...authLoginHistoryHint) (bool, error) {
|
||||
if data == nil || strings.TrimSpace(data.UserID) != "" {
|
||||
return false, nil
|
||||
}
|
||||
corpID := strings.TrimSpace(data.CorpID)
|
||||
if corpID == "" {
|
||||
return false, nil
|
||||
}
|
||||
cfg, err := authLoadProfiles(configDir)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if cfg == nil {
|
||||
return false, nil
|
||||
}
|
||||
sameCorp := make([]*authpkg.Profile, 0, len(cfg.Profiles))
|
||||
for i := range cfg.Profiles {
|
||||
profile := &cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) != corpID {
|
||||
continue
|
||||
}
|
||||
sameCorp = append(sameCorp, profile)
|
||||
}
|
||||
if len(sameCorp) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
hint := authLoginHistoryHint{}
|
||||
if len(hints) > 0 {
|
||||
hint = hints[0]
|
||||
}
|
||||
var candidate *authpkg.Profile
|
||||
if hint.Explicit {
|
||||
candidate = historicalProfileForSelector(corpID, hint.Selector, sameCorp)
|
||||
if candidate == nil {
|
||||
// An explicit account is a hard identity boundary. If that exact
|
||||
// historical hint no longer matches the token's organization, do
|
||||
// not silently substitute org-current, sole, or global-current.
|
||||
return false, nil
|
||||
}
|
||||
} else if len(sameCorp) > 1 {
|
||||
// Organization-current is a storage preference, not proof of which user
|
||||
// completed a fresh authorization. With multiple accounts, only an exact
|
||||
// user selection may be used when the token/contact response has no UID.
|
||||
return false, nil
|
||||
} else {
|
||||
candidate = sameCorp[0]
|
||||
}
|
||||
|
||||
updated := *data
|
||||
if hint.Explicit {
|
||||
updated.LegacyOrgScopedProfile = strings.TrimSpace(hint.Selector)
|
||||
}
|
||||
if strings.TrimSpace(updated.CorpName) == "" {
|
||||
updated.CorpName = strings.TrimSpace(candidate.CorpName)
|
||||
}
|
||||
if strings.TrimSpace(updated.UserName) == "" {
|
||||
updated.UserName = strings.TrimSpace(candidate.UserName)
|
||||
}
|
||||
*data = updated
|
||||
logging.AuthDebug(
|
||||
"auth.login.identity.resolved",
|
||||
"source", "local_profile_history_display_only",
|
||||
"corp_id", corpID,
|
||||
"user_id", updated.UserID,
|
||||
"user_name", updated.UserName,
|
||||
"corp_name", updated.CorpName,
|
||||
"identity_proven", false,
|
||||
)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func historicalProfileForSelector(corpID, selector string, profiles []*authpkg.Profile) *authpkg.Profile {
|
||||
selector = strings.TrimSpace(selector)
|
||||
if selector == "" {
|
||||
return nil
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: make([]authpkg.Profile, 0, len(profiles))}
|
||||
for _, profile := range profiles {
|
||||
if profile != nil {
|
||||
cfg.Profiles = append(cfg.Profiles, *profile)
|
||||
}
|
||||
}
|
||||
var stableMatch *authpkg.Profile
|
||||
for _, profile := range profiles {
|
||||
if profile == nil || strings.TrimSpace(profile.CorpID) != strings.TrimSpace(corpID) ||
|
||||
authpkg.ProfileSelectionSelector(*profile, cfg) != selector {
|
||||
continue
|
||||
}
|
||||
if stableMatch != nil {
|
||||
return nil
|
||||
}
|
||||
stableMatch = profile
|
||||
}
|
||||
if stableMatch != nil {
|
||||
return stableMatch
|
||||
}
|
||||
if selectedCorpID, userID, exact := authpkg.ParseIdentitySelector(selector); exact {
|
||||
if strings.TrimSpace(selectedCorpID) != strings.TrimSpace(corpID) {
|
||||
return nil
|
||||
}
|
||||
for _, profile := range profiles {
|
||||
if profile != nil && strings.TrimSpace(profile.UserID) == strings.TrimSpace(userID) {
|
||||
return profile
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
var named *authpkg.Profile
|
||||
for _, profile := range profiles {
|
||||
if profile == nil || strings.TrimSpace(profile.Name) != selector {
|
||||
continue
|
||||
}
|
||||
if named != nil {
|
||||
return nil
|
||||
}
|
||||
named = profile
|
||||
}
|
||||
if named != nil {
|
||||
return named
|
||||
}
|
||||
if selector == strings.TrimSpace(corpID) && len(profiles) == 1 {
|
||||
return profiles[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromToolResult(result *edition.ToolResult, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||
if result == nil {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
@@ -1369,14 +1641,14 @@ func contactProfileIdentityFromToolResult(result *edition.ToolResult) (contactPr
|
||||
if strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text)); ok {
|
||||
if identity, ok := contactProfileIdentityFromJSON([]byte(block.Text), expectedCorpIDs...); ok {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
|
||||
func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool) {
|
||||
func contactProfileIdentityFromJSON(data []byte, expectedCorpIDs ...string) (contactProfileIdentity, bool) {
|
||||
var payload struct {
|
||||
Result []struct {
|
||||
OrgEmployeeModel struct {
|
||||
@@ -1396,14 +1668,40 @@ func contactProfileIdentityFromJSON(data []byte) (contactProfileIdentity, bool)
|
||||
if len(payload.Result) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
org := payload.Result[0].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
identities := make([]contactProfileIdentity, 0, len(payload.Result))
|
||||
for i := range payload.Result {
|
||||
org := payload.Result[i].OrgEmployeeModel
|
||||
identity := contactProfileIdentity{
|
||||
CorpID: strings.TrimSpace(org.CorpID),
|
||||
CorpName: strings.TrimSpace(org.OrgName),
|
||||
UserID: firstNonEmptyString(org.UserID, org.UserIDLower, org.OrgUserID),
|
||||
UserName: firstNonEmptyString(org.OrgUserName, org.Name),
|
||||
}
|
||||
if identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != "" {
|
||||
identities = append(identities, identity)
|
||||
}
|
||||
}
|
||||
return identity, identity.CorpID != "" || identity.CorpName != "" || identity.UserID != "" || identity.UserName != ""
|
||||
if len(identities) == 0 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
expectedCorpID := ""
|
||||
if len(expectedCorpIDs) > 0 {
|
||||
expectedCorpID = strings.TrimSpace(expectedCorpIDs[0])
|
||||
}
|
||||
if expectedCorpID != "" {
|
||||
for _, identity := range identities {
|
||||
if identity.CorpID == expectedCorpID {
|
||||
return identity, true
|
||||
}
|
||||
}
|
||||
// Older contact responses omit corpId. A single result is still
|
||||
// unambiguous; multiple organization records without a target match
|
||||
// must fall back to local history instead of choosing result[0].
|
||||
if len(payload.Result) != 1 {
|
||||
return contactProfileIdentity{}, false
|
||||
}
|
||||
}
|
||||
return identities[0], true
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
|
||||
@@ -262,7 +262,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true"}); err == nil {
|
||||
t.Fatal("device error should propagate")
|
||||
}
|
||||
authDeviceLogin = func(*authpkg.DeviceFlowProvider, context.Context) (*authpkg.TokenData, error) {
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, _ context.Context) (*authpkg.TokenData, error) {
|
||||
if provider.IdentityEnricher == nil {
|
||||
t.Error("device login missing shared identity enricher")
|
||||
}
|
||||
return &authpkg.TokenData{AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour)}, nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"device": "true", "no-browser": "true"}); err != nil {
|
||||
@@ -275,7 +278,10 @@ func TestCrossPlatformCoverageAuthCoverageLoginFlows(t *testing.T) {
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, nil); err == nil {
|
||||
t.Fatal("oauth error should propagate")
|
||||
}
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, _ context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.IdentityEnricher == nil {
|
||||
t.Error("OAuth login missing shared identity enricher")
|
||||
}
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "a", ExpiresAt: time.Now().Add(time.Hour), RefreshToken: "r", RefreshExpAt: time.Now().Add(48 * time.Hour),
|
||||
CorpName: "Corp", CorpID: "ding1", UserName: "User", UserID: "u",
|
||||
@@ -356,8 +362,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{}, complete); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err == nil {
|
||||
t.Fatal("caller error should propagate")
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", &authCoverageCaller{err: errors.New("call")}, &authpkg.TokenData{CorpID: "ding"}); err != nil {
|
||||
t.Fatalf("contact failure must remain best effort: %v", err)
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(
|
||||
ctx,
|
||||
@@ -374,8 +380,8 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
mismatch := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err == nil {
|
||||
t.Fatal("corp mismatch should fail")
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", mismatch, &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}); err != nil {
|
||||
t.Fatalf("contact corp mismatch must remain best effort: %v", err)
|
||||
}
|
||||
same := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"u","name":"User"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, complete); err != nil {
|
||||
@@ -390,6 +396,25 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", same, data); err != nil || data.CorpName != "Corp" || data.UserID != "u" {
|
||||
t.Fatalf("enriched = %#v, %v", data, err)
|
||||
}
|
||||
known := &authpkg.TokenData{CorpID: "ding", UserID: "exchange-user", AccessToken: "token"}
|
||||
differentContactUser := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding","orgName":"Corp","userid":"other-user","name":"Other User"}}]}`}}}}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", differentContactUser, known); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if known.UserID != "exchange-user" || known.UserName != "" || known.CorpName != "Corp" {
|
||||
t.Fatalf("token-exchange identity was overwritten: %#v", known)
|
||||
}
|
||||
multiOrg := &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"result":[{"orgEmployeeModel":{"corpId":"other","userid":"other-user"}},{"orgEmployeeModel":{"corpId":"ding","orgName":"Target Corp","userid":"target-user","name":"Target User"}}]}`}}}}
|
||||
multiOrgData := &authpkg.TokenData{CorpID: "ding", AccessToken: "token"}
|
||||
if err := enrichAuthLoginProfileFromContact(ctx, "cfg", multiOrg, multiOrgData); err != nil || multiOrgData.UserID != "target-user" || multiOrgData.CorpName != "Target Corp" {
|
||||
t.Fatalf("multi-org contact selection = %#v, %v", multiOrgData, err)
|
||||
}
|
||||
if _, ok := contactProfileIdentityFromJSON(
|
||||
[]byte(`{"result":[{"orgEmployeeModel":{"corpId":"other-a","userid":"user-a"}},{"orgEmployeeModel":{"corpId":"other-b","userid":"user-b"}}]}`),
|
||||
"ding",
|
||||
); ok {
|
||||
t.Fatal("multiple nonmatching organizations must not select an arbitrary contact identity")
|
||||
}
|
||||
if _, ok := contactProfileIdentityFromToolResult(nil); ok {
|
||||
t.Fatal("nil result should not parse")
|
||||
}
|
||||
@@ -398,6 +423,570 @@ func TestCrossPlatformCoverageAuthCoverageContactEnrichment(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactFailureReusesOnlySameCorpHistoricalDisplayMetadata(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 1,
|
||||
Profiles: []authpkg.Profile{{
|
||||
CorpID: "ding_ecological_worker",
|
||||
CorpName: "Historical Corp",
|
||||
UserID: "external-user",
|
||||
UserName: "Historical Worker",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller edition.ToolCaller
|
||||
wantCorp string
|
||||
}{
|
||||
{
|
||||
name: "contact business error",
|
||||
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact has no identity",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{Text: `{"success":false}`}}}},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact identity is missing user id",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_ecological_worker","orgName":"Contact Corp"}}]}`,
|
||||
}}}},
|
||||
wantCorp: "Contact Corp",
|
||||
},
|
||||
{
|
||||
name: "ordinary contact error",
|
||||
caller: &authCoverageCaller{err: errors.New("network failure")},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "other contact business error",
|
||||
caller: &authCoverageCaller{err: apperrors.NewAPI(
|
||||
"permission denied",
|
||||
apperrors.WithReason("business_error"),
|
||||
)},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact caller unavailable",
|
||||
caller: nil,
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
{
|
||||
name: "contact returns another organization",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_other","userid":"other-user"}}]}`,
|
||||
}}}},
|
||||
wantCorp: "Fresh Corp",
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
data := &authpkg.TokenData{
|
||||
AccessToken: "new-access",
|
||||
RefreshToken: "new-refresh",
|
||||
CorpID: "ding_ecological_worker",
|
||||
CorpName: "Fresh Corp",
|
||||
}
|
||||
if err := enrichAuthLoginProfileFromContact(context.Background(), configDir, tc.caller, data); err != nil {
|
||||
t.Fatalf("contact failure blocked historical identity recovery: %v", err)
|
||||
}
|
||||
if data.UserID != "" || data.UserName != "Historical Worker" {
|
||||
t.Fatalf("historical metadata supplied UID evidence: %#v", data)
|
||||
}
|
||||
if data.CorpName != tc.wantCorp {
|
||||
t.Fatalf("corp name = %q, want %q", data.CorpName, tc.wantCorp)
|
||||
}
|
||||
if data.AccessToken != "new-access" || data.RefreshToken != "new-refresh" {
|
||||
t.Fatalf("new token material was changed: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactFailureDoesNotGuessHistoricalIdentity(t *testing.T) {
|
||||
businessErr := apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
corpID string
|
||||
profiles []authpkg.Profile
|
||||
callErr error
|
||||
}{
|
||||
{
|
||||
name: "same corp has two identities",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user-b"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "same corp has one identity and one blank profile",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ecological_worker", UserID: "external-user"},
|
||||
{CorpID: "ding_ecological_worker"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "identity belongs to another corp",
|
||||
corpID: "ding_ecological_worker",
|
||||
profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_other", UserID: "external-user"},
|
||||
},
|
||||
callErr: businessErr,
|
||||
},
|
||||
{
|
||||
name: "no historical identity",
|
||||
corpID: "ding_ecological_worker",
|
||||
callErr: businessErr,
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{Version: 2, Profiles: tc.profiles}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
data := &authpkg.TokenData{AccessToken: "new-access", CorpID: tc.corpID}
|
||||
err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
configDir,
|
||||
&authCoverageCaller{err: tc.callErr},
|
||||
data,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("contact failure must not block unresolved legacy login: %v", err)
|
||||
}
|
||||
if data.UserID != "" {
|
||||
t.Fatalf("ambiguous/cross-corp identity was reused: %#v", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageContactHistoryFallbackEdges(t *testing.T) {
|
||||
for _, data := range []*authpkg.TokenData{
|
||||
nil,
|
||||
{UserID: "known"},
|
||||
{},
|
||||
} {
|
||||
reused, err := enrichAuthLoginProfileFromHistory(t.TempDir(), data)
|
||||
if reused || err != nil {
|
||||
t.Fatalf("ineligible history fallback = %v, %v", reused, err)
|
||||
}
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
CorpID: "ding_external",
|
||||
CorpName: "Historical Corp",
|
||||
UserID: "external-user",
|
||||
UserName: "Historical Worker",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
data := &authpkg.TokenData{CorpID: "ding_external"}
|
||||
reused, err := enrichAuthLoginProfileFromHistory(configDir, data)
|
||||
if err != nil || !reused {
|
||||
t.Fatalf("history fallback = %v, %v", reused, err)
|
||||
}
|
||||
if data.CorpName != "Historical Corp" || data.UserName != "Historical Worker" || data.UserID != "" {
|
||||
t.Fatalf("history metadata = %#v", data)
|
||||
}
|
||||
|
||||
corruptDir := t.TempDir()
|
||||
if err := os.Mkdir(authpkg.ProfilesPath(corruptDir), 0o700); err != nil {
|
||||
t.Fatalf("create unreadable profiles path: %v", err)
|
||||
}
|
||||
if reused, err := enrichAuthLoginProfileFromHistory(corruptDir, &authpkg.TokenData{CorpID: "ding_external"}); reused || err == nil {
|
||||
t.Fatalf("corrupt history fallback = %v, %v; want load error", reused, err)
|
||||
}
|
||||
|
||||
businessErr := apperrors.NewAPI(
|
||||
"business error: success=false",
|
||||
apperrors.WithReason("business_error"),
|
||||
)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
caller *authCoverageCaller
|
||||
}{
|
||||
{
|
||||
name: "contact business error",
|
||||
caller: &authCoverageCaller{err: businessErr},
|
||||
},
|
||||
{
|
||||
name: "contact has no identity",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{}},
|
||||
},
|
||||
{
|
||||
name: "contact identity is missing user id",
|
||||
caller: &authCoverageCaller{result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Text: `{"result":[{"orgEmployeeModel":{"corpId":"ding_external"}}]}`,
|
||||
}}}},
|
||||
},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
corruptDir,
|
||||
tc.caller,
|
||||
&authpkg.TokenData{CorpID: "ding_external", AccessToken: "new-access"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("best-effort contact/history lookup blocked login: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginConfigPreservesHistoryIdentityHint(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldResolve := authResolveProfile
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() {
|
||||
authResolveProfile = oldResolve
|
||||
authLoadProfiles = oldLoad
|
||||
})
|
||||
|
||||
explicit := &authpkg.Profile{CorpID: "ding_same", UserID: "user_2", Name: "second"}
|
||||
current := &authpkg.Profile{CorpID: "ding_current", UserID: "current_user"}
|
||||
authResolveProfile = func(_ string, selector string) (*authpkg.Profile, error) {
|
||||
switch selector {
|
||||
case "ding_same:user_2":
|
||||
clone := *explicit
|
||||
return &clone, nil
|
||||
case "external-worker":
|
||||
return &authpkg.Profile{Name: "external-worker", CorpID: "ding_external"}, nil
|
||||
case "":
|
||||
clone := *current
|
||||
return &clone, nil
|
||||
default:
|
||||
return nil, errors.New("missing")
|
||||
}
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{}, nil
|
||||
}
|
||||
|
||||
cmd := newAuthLoginCommand(nil)
|
||||
root, _, _ := authCoverageRoot(cmd, "table", true)
|
||||
if err := root.PersistentFlags().Set("profile", "ding_same:user_2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := resolveAuthLoginConfig(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TargetCorpID != "ding_same" || cfg.HistoryProfileSelector != "ding_same:user_2" || !cfg.HistoryProfileSelectorExplicit {
|
||||
t.Fatalf("explicit login config = %#v", cfg)
|
||||
}
|
||||
if target, hint, exact, err := resolveAuthLoginTarget("cfg", "external-worker"); err != nil ||
|
||||
target != "ding_external" || hint != "ding_external" || !exact {
|
||||
t.Fatalf("blank-userId profile target = %q/%q/%v, %v", target, hint, exact, err)
|
||||
}
|
||||
|
||||
if err := root.PersistentFlags().Set("profile", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err = resolveAuthLoginConfig(cmd)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.TargetCorpID != "" || cfg.HistoryProfileSelector != "ding_current:current_user" || cfg.HistoryProfileSelectorExplicit {
|
||||
t.Fatalf("implicit login config constrained authorization target: %#v", cfg)
|
||||
}
|
||||
|
||||
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_same:missing"); err == nil {
|
||||
t.Fatal("missing exact profile must not be reinterpreted as a corpId")
|
||||
}
|
||||
if target, hint, explicitHint, err := resolveAuthLoginTarget("cfg", "ding_new"); err != nil || target != "ding_new" || hint != "" || explicitHint {
|
||||
t.Fatalf("new organization target = %q/%q/%v, %v", target, hint, explicitHint, err)
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_ambiguous", UserID: "user_1"},
|
||||
{CorpID: "ding_ambiguous", UserID: "user_2"},
|
||||
}}, nil
|
||||
}
|
||||
if _, _, _, err := resolveAuthLoginTarget("cfg", "ding_ambiguous"); err == nil {
|
||||
t.Fatal("ambiguous known organization must require an exact profile")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOAuthAndDeviceKeepFreshUnknownIdentityIsolatedFromExactHistory(t *testing.T) {
|
||||
oldResolve := authResolveProfile
|
||||
oldLoad := authLoadProfiles
|
||||
oldDevice := authDeviceLogin
|
||||
oldOAuth := authOAuthLogin
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
t.Cleanup(func() {
|
||||
authResolveProfile = oldResolve
|
||||
authLoadProfiles = oldLoad
|
||||
authDeviceLogin = oldDevice
|
||||
authOAuthLogin = oldOAuth
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
})
|
||||
|
||||
authResolveProfile = authpkg.ResolveProfile
|
||||
authLoadProfiles = authpkg.LoadProfiles
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
for _, flow := range []string{"oauth", "device"} {
|
||||
t.Run(flow, func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
keychainDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
// StorageDirEnv isolates file-backed keychains, while Windows uses
|
||||
// DPAPI-protected HKCU values. Give every flow its own namespace so
|
||||
// OAuth/device fixtures cannot leak into each other or later tests.
|
||||
t.Setenv(keychain.TestNamespaceEnv, keychainDir)
|
||||
t.Cleanup(func() {
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
t.Errorf("clean auth keychain fixture: %v", err)
|
||||
}
|
||||
})
|
||||
authpkg.SetRuntimeProfile("")
|
||||
|
||||
const (
|
||||
corpID = "ding_same"
|
||||
historicalUID = "user_a"
|
||||
exactSelector = corpID + ":" + historicalUID
|
||||
)
|
||||
oldToken := &authpkg.TokenData{
|
||||
AccessToken: "old-user-a-access",
|
||||
RefreshToken: "old-user-a-refresh",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
CorpName: "Same Corp",
|
||||
UserID: historicalUID,
|
||||
UserName: "Historical User A",
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, oldToken); err != nil {
|
||||
t.Fatalf("persist historical exact identity: %v", err)
|
||||
}
|
||||
|
||||
caller := &authCoverageCaller{err: errors.New("contact unavailable")}
|
||||
var enriched *authpkg.TokenData
|
||||
freshToken := func() *authpkg.TokenData {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "fresh-user-b-access-" + flow,
|
||||
RefreshToken: "fresh-user-b-refresh-" + flow,
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: corpID,
|
||||
}
|
||||
}
|
||||
persistUnknown := func(ctx context.Context, identityEnricher func(context.Context, *authpkg.TokenData) error) (*authpkg.TokenData, error) {
|
||||
if identityEnricher == nil {
|
||||
return nil, errors.New("missing identity enricher")
|
||||
}
|
||||
data := freshToken()
|
||||
if err := identityEnricher(ctx, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
enriched = data
|
||||
if data.UserID != "" {
|
||||
return nil, fmt.Errorf("historical profile supplied unproven userId %q", data.UserID)
|
||||
}
|
||||
if err := authpkg.SaveTokenData(configDir, data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
flags := map[string]string{"profile": exactSelector}
|
||||
switch flow {
|
||||
case "device":
|
||||
flags["device"] = "true"
|
||||
authDeviceLogin = func(provider *authpkg.DeviceFlowProvider, ctx context.Context) (*authpkg.TokenData, error) {
|
||||
return persistUnknown(ctx, provider.IdentityEnricher)
|
||||
}
|
||||
case "oauth":
|
||||
authOAuthLogin = func(provider *authpkg.OAuthProvider, ctx context.Context, _ bool) (*authpkg.TokenData, error) {
|
||||
if provider.TargetCorpID != corpID {
|
||||
return nil, fmt.Errorf("OAuth target corp = %q", provider.TargetCorpID)
|
||||
}
|
||||
return persistUnknown(ctx, provider.IdentityEnricher)
|
||||
}
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, caller, "table", true, flags); err != nil {
|
||||
t.Fatalf("%s login with unresolved fresh identity: %v", flow, err)
|
||||
}
|
||||
if enriched == nil || enriched.UserID != "" ||
|
||||
enriched.LegacyOrgScopedProfile != exactSelector ||
|
||||
enriched.CorpName != "Same Corp" ||
|
||||
enriched.UserName != "Historical User A" {
|
||||
t.Fatalf("%s history hint became identity evidence: %#v", flow, enriched)
|
||||
}
|
||||
|
||||
historical, err := authpkg.LoadTokenDataForProfile(configDir, exactSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("load historical exact identity: %v", err)
|
||||
}
|
||||
if historical.AccessToken != oldToken.AccessToken || historical.UserID != historicalUID {
|
||||
t.Fatalf("historical exact slot was overwritten: %#v", historical)
|
||||
}
|
||||
|
||||
profiles, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load profiles: %v", err)
|
||||
}
|
||||
var unresolved *authpkg.Profile
|
||||
for i := range profiles.Profiles {
|
||||
profile := &profiles.Profiles[i]
|
||||
if profile.CorpID == corpID && profile.UserID == "" {
|
||||
unresolved = profile
|
||||
break
|
||||
}
|
||||
}
|
||||
if unresolved == nil {
|
||||
t.Fatalf("fresh UID-less token did not create an unresolved profile: %#v", profiles.Profiles)
|
||||
}
|
||||
unresolvedSelector := authpkg.ProfileSelectionSelector(*unresolved, profiles)
|
||||
if unresolvedSelector == "" || unresolvedSelector == exactSelector {
|
||||
t.Fatalf("unresolved selector = %q", unresolvedSelector)
|
||||
}
|
||||
fresh, err := authpkg.LoadTokenDataForProfile(configDir, unresolvedSelector)
|
||||
if err != nil {
|
||||
t.Fatalf("load fresh unresolved identity: %v", err)
|
||||
}
|
||||
if fresh.AccessToken != "fresh-user-b-access-"+flow || fresh.UserID != "" {
|
||||
t.Fatalf("fresh token was not isolated in unresolved org slot: %#v", fresh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHistoricalIdentityPriorityAndBlankUserID(t *testing.T) {
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() { authLoadProfiles = oldLoad })
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return nil, nil }
|
||||
if reused, err := enrichAuthLoginProfileFromHistory("cfg", &authpkg.TokenData{CorpID: "ding_same"}); reused || err != nil {
|
||||
t.Fatalf("nil history registry = reused=%v err=%v", reused, err)
|
||||
}
|
||||
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
CurrentProfile: "ding_same:user_1",
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"ding_same": "ding_same:user_2",
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||
},
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||
|
||||
explicitData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err := enrichAuthLoginProfileFromHistory("cfg", explicitData, authLoginHistoryHint{Selector: "ding_same:user_1", Explicit: true})
|
||||
if err != nil || !reused || explicitData.UserID != "" ||
|
||||
explicitData.LegacyOrgScopedProfile != "ding_same:user_1" ||
|
||||
explicitData.CorpName != "Same Corp" || explicitData.UserName != "First" {
|
||||
t.Fatalf("explicit history selection = %#v, reused=%v err=%v", explicitData, reused, err)
|
||||
}
|
||||
mismatchedHintData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", mismatchedHintData, authLoginHistoryHint{Selector: "ding_other:user_9", Explicit: true})
|
||||
if err != nil || reused || mismatchedHintData.UserID != "" {
|
||||
t.Fatalf("cross-corp explicit hint reused another identity: %#v, reused=%v err=%v", mismatchedHintData, reused, err)
|
||||
}
|
||||
orgCurrentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", orgCurrentData)
|
||||
if err != nil || reused || orgCurrentData.UserID != "" {
|
||||
t.Fatalf("implicit multi-account org-current was treated as identity proof: %#v, reused=%v err=%v", orgCurrentData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{cfg.Profiles[1]}
|
||||
soleData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", soleData)
|
||||
if err != nil || !reused || soleData.UserID != "" ||
|
||||
soleData.CorpName != "Same Corp" || soleData.UserName != "Second" {
|
||||
t.Fatalf("sole history selection = %#v, reused=%v err=%v", soleData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_1", UserName: "First"},
|
||||
{CorpID: "ding_same", CorpName: "Same Corp", UserID: "user_2", UserName: "Second"},
|
||||
}
|
||||
cfg.OrgCurrentProfiles = nil
|
||||
currentData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", currentData)
|
||||
if err != nil || reused || currentData.UserID != "" {
|
||||
t.Fatalf("implicit multi-account current was treated as identity proof: %#v, reused=%v err=%v", currentData, reused, err)
|
||||
}
|
||||
|
||||
cfg.CurrentProfile = ""
|
||||
ambiguousData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", ambiguousData)
|
||||
if err != nil || reused || ambiguousData.UserID != "" {
|
||||
t.Fatalf("ambiguous history selection = %#v, reused=%v err=%v", ambiguousData, reused, err)
|
||||
}
|
||||
|
||||
cfg.Profiles = []authpkg.Profile{{
|
||||
Name: "external-worker", CorpID: "ding_same", CorpName: "Legacy Corp", UserName: "Legacy Worker",
|
||||
}}
|
||||
blankData := &authpkg.TokenData{CorpID: "ding_same"}
|
||||
reused, err = enrichAuthLoginProfileFromHistory("cfg", blankData, authLoginHistoryHint{Selector: "external-worker", Explicit: true})
|
||||
if err != nil || !reused || blankData.UserID != "" || blankData.LegacyOrgScopedProfile != "external-worker" || blankData.CorpName != "Legacy Corp" || blankData.UserName != "Legacy Worker" {
|
||||
t.Fatalf("blank-userId history selection = %#v, reused=%v err=%v", blankData, reused, err)
|
||||
}
|
||||
contactBlankData := &authpkg.TokenData{CorpID: "ding_same", AccessToken: "new-token"}
|
||||
if err := enrichAuthLoginProfileFromContact(
|
||||
context.Background(),
|
||||
"cfg",
|
||||
&authCoverageCaller{err: errors.New("contact unavailable")},
|
||||
contactBlankData,
|
||||
authLoginHistoryHint{Selector: "external-worker", Explicit: true},
|
||||
); err != nil {
|
||||
t.Fatalf("blank-userId history must keep contact best effort: %v", err)
|
||||
}
|
||||
if contactBlankData.LegacyOrgScopedProfile != "external-worker" {
|
||||
t.Fatalf("blank-userId contact fallback did not authorize the historical organization slot: %#v", contactBlankData)
|
||||
}
|
||||
|
||||
profiles := []*authpkg.Profile{
|
||||
nil,
|
||||
{Name: "duplicate", CorpID: "ding_same", UserID: "user_1"},
|
||||
{Name: "duplicate", CorpID: "ding_same", UserID: "user_2"},
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
selector string
|
||||
profiles []*authpkg.Profile
|
||||
want *authpkg.Profile
|
||||
}{
|
||||
{name: "empty selector", selector: "", profiles: profiles},
|
||||
{name: "missing exact identity", selector: "ding_same:missing", profiles: profiles},
|
||||
{name: "duplicate name", selector: "duplicate", profiles: profiles},
|
||||
{name: "unmatched name", selector: "not-found", profiles: profiles},
|
||||
{name: "sole organization selector", selector: "ding_same", profiles: profiles[1:2], want: profiles[1]},
|
||||
} {
|
||||
t.Run("selector "+tc.name, func(t *testing.T) {
|
||||
if got := historicalProfileForSelector("ding_same", tc.selector, tc.profiles); got != tc.want {
|
||||
t.Fatalf("historicalProfileForSelector(%q) = %#v, want %#v", tc.selector, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthCoverageDefaultSeamClosures(t *testing.T) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
@@ -748,7 +1337,7 @@ func TestCrossPlatformCoverageAuthCoveragePortableExchangeAndReset(t *testing.T)
|
||||
if err := importCmd.RunE(badForce, nil); err == nil {
|
||||
t.Fatal("invalid force flag should fail")
|
||||
}
|
||||
_, out, _ = authCoverageRoot(importCmd, "table", false)
|
||||
_, _, _ = authCoverageRoot(importCmd, "table", false)
|
||||
if err := importCmd.RunE(importCmd, nil); err == nil {
|
||||
t.Fatal("missing input should fail")
|
||||
}
|
||||
|
||||
@@ -195,6 +195,15 @@ func TestCrossPlatformCoverageAuthImportRejectsWindowsDPAPIBackend(t *testing.T)
|
||||
}
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
t.Setenv(keychain.StorageDirEnv, keychainDir)
|
||||
// Windows stores credentials in HKCU rather than StorageDirEnv. Use a
|
||||
// fresh registry namespace so this zero-state assertion cannot inherit a
|
||||
// token from an earlier test in the same package binary.
|
||||
t.Setenv(keychain.TestNamespaceEnv, root)
|
||||
t.Cleanup(func() {
|
||||
if err := keychain.RemoveAuthTokenEntries(keychain.Service); err != nil {
|
||||
t.Errorf("clean import guard keychain fixture: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
importCmd := NewRootCommand()
|
||||
importCmd.SetOut(&bytes.Buffer{})
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageAuthLoginUsesStableBlankProfileForPostLoginAuthorization(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
oldOAuth := authOAuthLogin
|
||||
oldLoadProfiles := authLoadProfiles
|
||||
oldRecommend := authRunLoginRecommend
|
||||
oldInteractive := authLoginInteractiveTerminal
|
||||
oldResolve := authResolveProfile
|
||||
t.Cleanup(func() {
|
||||
authOAuthLogin = oldOAuth
|
||||
authLoadProfiles = oldLoadProfiles
|
||||
authRunLoginRecommend = oldRecommend
|
||||
authLoginInteractiveTerminal = oldInteractive
|
||||
authResolveProfile = oldResolve
|
||||
})
|
||||
|
||||
const corpID = "corp_post_login_blank"
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
{Name: "Fixture Organization", CorpID: corpID, CorpName: "Fixture Organization"},
|
||||
{Name: "Exact Fixture", CorpID: corpID, CorpName: "Fixture Organization", UserID: "identity_exact"},
|
||||
}}
|
||||
wantSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
if wantSelector == "" || wantSelector == corpID {
|
||||
t.Fatalf("blank selector = %q, want a stable account selector", wantSelector)
|
||||
}
|
||||
authResolveProfile = func(string, string) (*authpkg.Profile, error) {
|
||||
return nil, errors.New("no implicit profile")
|
||||
}
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) { return cfg, nil }
|
||||
authOAuthLogin = func(*authpkg.OAuthProvider, context.Context, bool) (*authpkg.TokenData, error) {
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "new-access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: corpID,
|
||||
}, nil
|
||||
}
|
||||
authLoginInteractiveTerminal = func() bool { return false }
|
||||
seenSelector := ""
|
||||
authRunLoginRecommend = func(context.Context, edition.ToolCaller, io.Writer, pat.LoginRecommendOptions) error {
|
||||
seenSelector = authpkg.RuntimeProfile()
|
||||
return nil
|
||||
}
|
||||
if _, _, err := authCoverageRunLogin(t, nil, "table", true, map[string]string{"recommend": "true"}); err != nil {
|
||||
t.Fatalf("blank-profile login error = %v", err)
|
||||
}
|
||||
if seenSelector != wantSelector {
|
||||
t.Fatalf("post-login runtime selector = %q, want %q", seenSelector, wantSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthStatusAndLogoutPreserveExactSelectors(t *testing.T) {
|
||||
t.Run("status canonicalizes a known identity", func(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
const exactSelector = "corp_status_fixture:identity_status_fixture"
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Status Fixture",
|
||||
CorpID: "corp_status_fixture",
|
||||
UserID: "identity_status_fixture",
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
oldStatus := authOAuthStatus
|
||||
t.Cleanup(func() { authOAuthStatus = oldStatus })
|
||||
seenSelector := ""
|
||||
authOAuthStatus = func(*authpkg.OAuthProvider) (*authpkg.TokenData, error) {
|
||||
seenSelector = authpkg.RuntimeProfile()
|
||||
return &authpkg.TokenData{
|
||||
AccessToken: "access",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
CorpID: "corp_status_fixture",
|
||||
UserID: "identity_status_fixture",
|
||||
}, nil
|
||||
}
|
||||
cmd := newAuthStatusCommand()
|
||||
_, _, _ = authCoverageRoot(cmd, "table", false)
|
||||
if err := cmd.Flags().Set("profile", " Status Fixture "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := cmd.RunE(cmd, nil); err != nil {
|
||||
t.Fatalf("auth status error = %v", err)
|
||||
}
|
||||
if seenSelector != exactSelector {
|
||||
t.Fatalf("status runtime selector = %q, want %q", seenSelector, exactSelector)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("logout keeps a blank local selector", func(t *testing.T) {
|
||||
oldResolve := authResolveProfileDeletion
|
||||
oldLoad := authLoadTokenForProfile
|
||||
oldRevoke := authRevokeTokenForData
|
||||
oldDelete := authDeleteProfileToken
|
||||
t.Cleanup(func() {
|
||||
authResolveProfileDeletion = oldResolve
|
||||
authLoadTokenForProfile = oldLoad
|
||||
authRevokeTokenForData = oldRevoke
|
||||
authDeleteProfileToken = oldDelete
|
||||
})
|
||||
|
||||
const selector = "legacy-external-worker"
|
||||
authResolveProfileDeletion = func(string, string) (*authpkg.Profile, bool, error) {
|
||||
return &authpkg.Profile{CorpID: "corp_logout_blank"}, true, nil
|
||||
}
|
||||
loadedSelector := ""
|
||||
authLoadTokenForProfile = func(_ string, got string) (*authpkg.TokenData, error) {
|
||||
loadedSelector = got
|
||||
return &authpkg.TokenData{CorpID: "corp_logout_blank"}, nil
|
||||
}
|
||||
authRevokeTokenForData = func(context.Context, *authpkg.TokenData) error { return nil }
|
||||
deletedSelector := ""
|
||||
authDeleteProfileToken = func(_ string, got string) error {
|
||||
deletedSelector = got
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := logoutOneProfile(nil, context.Background(), "cfg", " "+selector+" "); err != nil {
|
||||
t.Fatalf("logoutOneProfile() error = %v", err)
|
||||
}
|
||||
if loadedSelector != selector || deletedSelector != selector {
|
||||
t.Fatalf("blank logout selectors = load %q delete %q, want %q", loadedSelector, deletedSelector, selector)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthHistorySelectorRemainingBranches(t *testing.T) {
|
||||
if got := authLoginHistorySelector("cfg", nil); got != "" {
|
||||
t.Fatalf("nil history selector = %q", got)
|
||||
}
|
||||
|
||||
oldLoad := authLoadProfiles
|
||||
t.Cleanup(func() { authLoadProfiles = oldLoad })
|
||||
authLoadProfiles = func(string) (*authpkg.ProfilesConfig, error) {
|
||||
return nil, errors.New("profiles unavailable")
|
||||
}
|
||||
profile := &authpkg.Profile{CorpID: "corp_history", UserID: "identity_history"}
|
||||
if got := authLoginHistorySelector("cfg", profile); got != "corp_history:identity_history" {
|
||||
t.Fatalf("history selector fallback = %q", got)
|
||||
}
|
||||
|
||||
duplicateA := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
|
||||
duplicateB := &authpkg.Profile{CorpID: "corp_history", UserID: "duplicate_identity"}
|
||||
if got := historicalProfileForSelector(
|
||||
"corp_history",
|
||||
"corp_history:duplicate_identity",
|
||||
[]*authpkg.Profile{duplicateA, duplicateB},
|
||||
); got != nil {
|
||||
t.Fatalf("duplicate stable identity selected %#v", got)
|
||||
}
|
||||
|
||||
// Whitespace keeps the raw selector from matching the stable string while
|
||||
// ParseIdentitySelector still resolves its components.
|
||||
exactFallback := &authpkg.Profile{CorpID: "corp_history", UserID: "fallback_identity"}
|
||||
if got := historicalProfileForSelector(
|
||||
"corp_history",
|
||||
"corp_history : fallback_identity",
|
||||
[]*authpkg.Profile{exactFallback},
|
||||
); got != exactFallback {
|
||||
t.Fatalf("exact history fallback = %#v, want %#v", got, exactFallback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProfileSwitchLegacyBlankAndNormalizedIdentityPointers(t *testing.T) {
|
||||
t.Run("one legacy blank name", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{
|
||||
{Name: "Fixture Organization", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization"},
|
||||
{Name: "Exact Fixture", CorpID: "corp_profile_fixture", CorpName: "Fixture Organization", UserID: "identity_exact"},
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "Fixture Organization", cfg); got != 0 {
|
||||
t.Fatalf("legacy blank profile index = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("duplicate legacy names fall through to blank-name compatibility", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{
|
||||
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
|
||||
{Name: "duplicate-legacy", CorpID: "corp_profile_fixture"},
|
||||
}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "duplicate-legacy", cfg); got != 0 {
|
||||
t.Fatalf("duplicate legacy fallback index = %d, want 0", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalized exact identity", func(t *testing.T) {
|
||||
profiles := []authpkg.Profile{{CorpID: "corp_profile_fixture", UserID: "identity_exact"}}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: profiles}
|
||||
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : identity_exact", cfg); got != 0 {
|
||||
t.Fatalf("normalized exact profile index = %d, want 0", got)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(profiles, "corp_profile_fixture : missing", cfg); got != -1 {
|
||||
t.Fatalf("missing normalized exact profile index = %d, want -1", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRunnerPreservesBlankSelectorInSingleAndMultiRuns(t *testing.T) {
|
||||
exact := authLogoutTestToken("corp_runner_blank")
|
||||
exact.UserID = "identity_exact_runner"
|
||||
other := authLogoutTestToken("corp_runner_other")
|
||||
configDir := setupAuthLogoutProfiles(t, exact, other)
|
||||
blank := authLogoutTestToken("corp_runner_blank")
|
||||
blank.AccessToken = "access-unresolved-runner"
|
||||
blank.RefreshToken = "refresh-unresolved-runner"
|
||||
blank.UserID = ""
|
||||
blank.UserName = ""
|
||||
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
|
||||
t.Fatalf("SaveTokenData(blank) error = %v", err)
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
blankSelector := ""
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID == blank.CorpID && profile.UserID == "" {
|
||||
blankSelector = authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
break
|
||||
}
|
||||
}
|
||||
if blankSelector == "" || blankSelector == blank.CorpID {
|
||||
t.Fatalf("blank runner selector = %q, want exact local selector", blankSelector)
|
||||
}
|
||||
|
||||
runner := &runtimeRunner{fallback: multiProfileFallbackRunner{}}
|
||||
invocation := executor.Invocation{
|
||||
Kind: "helper_invocation",
|
||||
CanonicalProduct: "contact",
|
||||
Tool: "get_current_user_profile",
|
||||
}
|
||||
authpkg.SetRuntimeProfile(blankSelector)
|
||||
result, err := runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("single blank Run() error = %v", err)
|
||||
}
|
||||
content := result.Response["content"].(map[string]any)
|
||||
if got := content["runtimeProfile"]; got != blankSelector {
|
||||
t.Fatalf("single blank runtime profile = %#v, want %q", got, blankSelector)
|
||||
}
|
||||
if got := authpkg.RuntimeProfile(); got != blankSelector {
|
||||
t.Fatalf("single blank runtime restoration = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(blankSelector + ",corp_runner_other")
|
||||
result, err = runner.Run(context.Background(), invocation)
|
||||
if err != nil {
|
||||
t.Fatalf("multi blank Run() error = %v", err)
|
||||
}
|
||||
entries := result.Response["content"].(map[string]any)["profiles"].([]any)
|
||||
if len(entries) != 2 {
|
||||
t.Fatalf("multi blank profiles = %#v, want two", entries)
|
||||
}
|
||||
first := entries[0].(map[string]any)
|
||||
if first["selector"] != blankSelector || first["profile"] != blankSelector || first["userId"] != "" {
|
||||
t.Fatalf("multi blank first entry = %#v", first)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusSelectorCanonicalFallback(t *testing.T) {
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
identity := personal.Identity{
|
||||
CorpID: "corp_event_fallback",
|
||||
UserID: "identity_event_fallback",
|
||||
SourceID: "open",
|
||||
}
|
||||
if got := personalBusProfileSelector(t.TempDir(), identity); got != "corp_event_fallback:identity_event_fallback" {
|
||||
t.Fatalf("personal bus fallback selector = %q", got)
|
||||
}
|
||||
args := personalBusSpawnArgs(identity, "", "", " ")
|
||||
if got := strings.Join(args, " "); !strings.Contains(got, "--profile corp_event_fallback:identity_event_fallback") {
|
||||
t.Fatalf("personal bus default profile args = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
func TestPATFreshAuthorizationSaveUsesLoginIsolationBoundary(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
const (
|
||||
corpID = "corp_pat_login_boundary"
|
||||
userID = "exact-user"
|
||||
)
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{
|
||||
{Name: "External Account", CorpID: corpID, CorpName: "PAT Boundary Organization"},
|
||||
{Name: "Exact Account", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
cfg.PrimaryProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
blank := &authpkg.TokenData{AccessToken: "existing-unresolved", CorpID: corpID, CorpName: "PAT Boundary Organization"}
|
||||
exact := &authpkg.TokenData{AccessToken: "existing-exact", CorpID: corpID, CorpName: "PAT Boundary Organization", UserID: userID}
|
||||
if err := authpkg.SaveTokenDataKeychainForCorpID(corpID, blank); err != nil {
|
||||
t.Fatalf("save unresolved token: %v", err)
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForIdentity(corpID, userID, exact); err != nil {
|
||||
t.Fatalf("save exact token: %v", err)
|
||||
}
|
||||
previousRuntimeProfile := authpkg.RuntimeProfile()
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile(previousRuntimeProfile) })
|
||||
|
||||
fresh := &authpkg.TokenData{AccessToken: "pat-fresh-unknown", CorpID: corpID, CorpName: "PAT Boundary Organization"}
|
||||
err := patSaveTokenData(configDir, fresh)
|
||||
if err == nil || !strings.Contains(err.Error(), "fresh UID-less token") {
|
||||
t.Fatalf("patSaveTokenData() error = %v, want unresolved-sibling protection", err)
|
||||
}
|
||||
persisted, loadErr := authpkg.LoadTokenDataKeychainForCorpID(corpID)
|
||||
if loadErr != nil || persisted.AccessToken != blank.AccessToken || persisted.UserID != "" {
|
||||
t.Fatalf("PAT save changed unresolved sibling: token=%#v err=%v", persisted, loadErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManualLoginSaveRepairsHalfMigratedGlobalBeforeOverwrite(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
const (
|
||||
corpID = "corp_manual_login_boundary"
|
||||
userID = "legacy-user"
|
||||
)
|
||||
selector := corpID + ":" + userID
|
||||
if err := authpkg.SaveProfiles(configDir, &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
CurrentProfile: selector,
|
||||
Profiles: []authpkg.Profile{{
|
||||
Name: "Legacy Exact Account", CorpID: corpID, CorpName: "Manual Boundary Organization", UserID: userID,
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
legacy := &authpkg.TokenData{AccessToken: "only-legacy-copy", CorpID: corpID, CorpName: "Manual Boundary Organization"}
|
||||
if err := authpkg.SaveTokenDataKeychain(legacy); err != nil {
|
||||
t.Fatalf("save half-migrated global: %v", err)
|
||||
}
|
||||
manual := &authpkg.TokenData{AccessToken: "manual-default", ExpiresAt: time.Now().Add(time.Hour)}
|
||||
if err := authSaveTokenData(configDir, manual); err != nil {
|
||||
t.Fatalf("authSaveTokenData(manual) error = %v", err)
|
||||
}
|
||||
org, err := authpkg.LoadTokenDataKeychainForCorpID(corpID)
|
||||
if err != nil || org.AccessToken != legacy.AccessToken || org.UserID != "" {
|
||||
t.Fatalf("organization repair = %#v, %v", org, err)
|
||||
}
|
||||
identity, err := authpkg.LoadTokenDataKeychainForIdentity(corpID, userID)
|
||||
if err != nil || identity.AccessToken != legacy.AccessToken || identity.UserID != userID {
|
||||
t.Fatalf("identity repair = %#v, %v", identity, err)
|
||||
}
|
||||
global, err := authpkg.LoadTokenDataKeychain()
|
||||
if err != nil || global.AccessToken != manual.AccessToken || global.CorpID != "" {
|
||||
t.Fatalf("manual global = %#v, %v", global, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
)
|
||||
|
||||
func blankProfileSelectorAppFixture(blankName, corpName string) *authpkg.ProfilesConfig {
|
||||
const (
|
||||
corpID = "corp_selector_fixture"
|
||||
exactUserID = "identity_exact_fixture"
|
||||
)
|
||||
exactSelector := corpID + ":" + exactUserID
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
PrimaryProfile: exactSelector,
|
||||
PreviousProfile: exactSelector,
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
corpID: exactSelector,
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "Exact Fixture Account",
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
UserID: exactUserID,
|
||||
UserName: "Exact Fixture Account",
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
{
|
||||
Name: blankName,
|
||||
CorpID: corpID,
|
||||
CorpName: corpName,
|
||||
Status: authpkg.ProfileStatusActive,
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func captureProfileListSelectors(t *testing.T, cfg *authpkg.ProfilesConfig) ([]string, []profileView) {
|
||||
t.Helper()
|
||||
originalLoadToken := profileLoadTokenData
|
||||
selectors := make([]string, 0, len(cfg.Profiles))
|
||||
profileLoadTokenData = func(_ string, selector string) (*authpkg.TokenData, error) {
|
||||
selectors = append(selectors, selector)
|
||||
return nil, authpkg.ErrTokenDataNotFound
|
||||
}
|
||||
t.Cleanup(func() { profileLoadTokenData = originalLoadToken })
|
||||
views := profileViews("unused-config-dir", cfg)
|
||||
return selectors, views
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameMatchingCorpNameRoundTripsThroughListAndTUI(t *testing.T) {
|
||||
cfg := blankProfileSelectorAppFixture("Fixture Organization", "Fixture Organization")
|
||||
blank := cfg.Profiles[1]
|
||||
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
|
||||
|
||||
if blankSelector == blank.Name || blankSelector == blank.CorpID {
|
||||
t.Fatalf("unsafe blank selector = %q, want reserved exact selector", blankSelector)
|
||||
}
|
||||
if got := profileCLISelector(blank, cfg); got != blankSelector {
|
||||
t.Errorf("profileCLISelector(blank) = %q, want %q", got, blankSelector)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
|
||||
t.Errorf("profileSwitchProfileIndex(blank current) = %d, want 1", got)
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
|
||||
if model.selected != 1 {
|
||||
t.Errorf("TUI selected index = %d, want blank profile index 1", model.selected)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != blankSelector {
|
||||
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
selectors, views := captureProfileListSelectors(t, cfg)
|
||||
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
|
||||
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
|
||||
}
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("profile list views = %#v, want two entries", views)
|
||||
}
|
||||
if views[0].IsCurrent {
|
||||
t.Error("exact account should not be marked current when blank local selector is current")
|
||||
}
|
||||
if views[1].Profile != blankSelector || !views[1].IsCurrent {
|
||||
t.Errorf("blank list view = %#v, want local selector marked current", views[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBlankProfileNameContainingColonWinsOverIdentityParsingInListAndTUI(t *testing.T) {
|
||||
cfg := blankProfileSelectorAppFixture("legacy:outsourced", "Fixture Organization")
|
||||
blank := cfg.Profiles[1]
|
||||
blankSelector := authpkg.ProfileSelectionSelector(blank, cfg)
|
||||
|
||||
if blankSelector == blank.Name {
|
||||
t.Fatalf("colon-containing name leaked as selector %q", blankSelector)
|
||||
}
|
||||
if _, _, parsedAsIdentity := authpkg.ParseIdentitySelector(blankSelector); parsedAsIdentity {
|
||||
t.Fatalf("stable blank selector %q was parsed as an identity", blankSelector)
|
||||
}
|
||||
if got := profileCLISelector(blank, cfg); got != blankSelector {
|
||||
t.Errorf("profileCLISelector(colon blank) = %q, want %q", got, blankSelector)
|
||||
}
|
||||
if got := profileSwitchProfileIndex(cfg.Profiles, cfg.CurrentProfile, cfg); got != 1 {
|
||||
t.Errorf("profileSwitchProfileIndex(colon blank current) = %d, want 1", got)
|
||||
}
|
||||
model := newProfileSwitchTUIModel(cfg, cfg.CurrentProfile)
|
||||
if model.selected != 1 {
|
||||
t.Errorf("TUI selected index = %d, want colon-name blank profile index 1", model.selected)
|
||||
}
|
||||
if got := model.selectedCorpID(); got != blankSelector {
|
||||
t.Errorf("TUI selected selector = %q, want %q", got, blankSelector)
|
||||
}
|
||||
|
||||
selectors, views := captureProfileListSelectors(t, cfg)
|
||||
if len(selectors) != 2 || selectors[0] != cfg.PreviousProfile || selectors[1] != blankSelector {
|
||||
t.Errorf("profile list token selectors = %#v, want exact then %q", selectors, blankSelector)
|
||||
}
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("profile list views = %#v, want two entries", views)
|
||||
}
|
||||
if views[0].IsCurrent {
|
||||
t.Error("exact account should not be marked current when colon-name blank selector is current")
|
||||
}
|
||||
if views[1].Profile != blankSelector || !views[1].IsCurrent {
|
||||
t.Errorf("colon-name blank list view = %#v, want local selector marked current", views[1])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
stderrors "errors"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline/handlers"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func TestAllDistributionBooleanFlagTypesNormalizeDetachedLiterals(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
unique := make(map[string]pipeline.FlagInfo)
|
||||
var visit func(*cobra.Command)
|
||||
visit = func(command *cobra.Command) {
|
||||
for _, spec := range pipeline.FlagInfoFromCommand(command) {
|
||||
if spec.Type != "bool" && spec.Type != "boolean" {
|
||||
continue
|
||||
}
|
||||
key := strings.Join([]string{spec.Name, spec.Shorthand, spec.Type}, "\x00")
|
||||
unique[key] = spec
|
||||
}
|
||||
for _, child := range command.Commands() {
|
||||
visit(child)
|
||||
}
|
||||
}
|
||||
visit(root)
|
||||
|
||||
keys := make([]string, 0, len(unique))
|
||||
for key := range unique {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if len(keys) < 80 {
|
||||
t.Fatalf("boolean flag contract coverage is unexpectedly small: %d", len(keys))
|
||||
}
|
||||
|
||||
for _, key := range keys {
|
||||
spec := unique[key]
|
||||
for _, value := range []string{"true", "false"} {
|
||||
t.Run(spec.Name+"/"+value, func(t *testing.T) {
|
||||
ctx := &pipeline.Context{
|
||||
Command: "dws contract probe",
|
||||
Args: []string{"--" + spec.Name, value},
|
||||
FlagSpecs: []pipeline.FlagInfo{spec},
|
||||
}
|
||||
if err := (handlers.BoolValueHandler{}).Handle(ctx); err != nil {
|
||||
t.Fatalf("BoolValueHandler.Handle() error = %v", err)
|
||||
}
|
||||
want := []string{"--" + spec.Name + "=" + value}
|
||||
if !reflect.DeepEqual(ctx.Args, want) {
|
||||
t.Fatalf("normalized args = %v, want %v", ctx.Args, want)
|
||||
}
|
||||
|
||||
flags := pflag.NewFlagSet(spec.Name, pflag.ContinueOnError)
|
||||
flags.Bool(spec.Name, false, "")
|
||||
if err := flags.Parse(ctx.Args); err != nil {
|
||||
t.Fatalf("pflag rejected normalized args %v: %v", ctx.Args, err)
|
||||
}
|
||||
got, err := flags.GetBool(spec.Name)
|
||||
if err != nil || got != (value == "true") || !flags.Changed(spec.Name) {
|
||||
t.Fatalf("parsed %s = %v, changed=%v, error=%v", spec.Name, got, flags.Changed(spec.Name), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
t.Logf("verified detached boolean syntax for %d distinct distribution flag contracts", len(keys))
|
||||
}
|
||||
|
||||
func TestBooleanSyntaxPreservesDefaultsRequiredAndChangedContracts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
flag string
|
||||
value string
|
||||
wantDefault string
|
||||
wantValue string
|
||||
}{
|
||||
{name: "root default false", path: "chat bot find", flag: "dry-run", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "root mock default false", path: "chat bot find", flag: "mock", value: "true", wantDefault: "false", wantValue: "true"},
|
||||
{name: "local force default false", path: "upgrade", flag: "force", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "local default true", path: "sheet find", flag: "match-case", value: "false", wantDefault: "true", wantValue: "false"},
|
||||
{name: "required explicit false", path: "contact dept create", flag: "create-dept-group", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
{name: "changed false remains explicit", path: "sheet csv-put", flag: "allow-overwrite", value: "false", wantDefault: "false", wantValue: "false"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
leaf := resolveParamLeaf(root, test.path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("command %q is not runnable", test.path)
|
||||
}
|
||||
flag := booleanContractFlag(leaf, test.flag)
|
||||
if flag == nil || flag.DefValue != test.wantDefault || flag.Changed {
|
||||
t.Fatalf("initial --%s contract = %#v, want default %q and unchanged", test.flag, flag, test.wantDefault)
|
||||
}
|
||||
|
||||
pathArgs := strings.Fields(test.path)
|
||||
rawArgs := append(append([]string(nil), pathArgs...), "--"+test.flag, test.value)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(pathArgs):]
|
||||
if err := leaf.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
flag = booleanContractFlag(leaf, test.flag)
|
||||
if flag == nil || flag.Value.String() != test.wantValue || !flag.Changed {
|
||||
t.Fatalf("final --%s contract = %#v, want value %q and changed", test.flag, flag, test.wantValue)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetachedDryRunValuesReachTheExpectedFinalDispatchBoundary(t *testing.T) {
|
||||
base := []string{
|
||||
"mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
|
||||
}
|
||||
|
||||
bareArgs := append(append([]string(nil), base...), "--dry-run")
|
||||
_, barePreview, bareAttempts, bareErr := executeParamAliasDryRunE2E(t, bareArgs...)
|
||||
if bareErr != nil || !barePreview.DryRun || barePreview.Executed || len(bareAttempts) != 0 {
|
||||
t.Fatalf("bare dry-run = preview:%#v attempts:%#v error:%v", barePreview, bareAttempts, bareErr)
|
||||
}
|
||||
|
||||
trueArgs := append(append([]string(nil), base...), "--dry-run", "TRUE")
|
||||
trueCtx, truePreview, trueAttempts, trueErr := executeParamAliasDryRunE2E(t, trueArgs...)
|
||||
if trueErr != nil || !reflect.DeepEqual(truePreview, barePreview) || len(trueAttempts) != 0 {
|
||||
t.Fatalf("detached true = context:%#v preview:%#v attempts:%#v error:%v", trueCtx, truePreview, trueAttempts, trueErr)
|
||||
}
|
||||
if !hasBooleanCorrection(trueCtx, "--dry-run TRUE", "--dry-run=true") {
|
||||
t.Fatalf("detached true correction = %#v", trueCtx)
|
||||
}
|
||||
|
||||
falseCases := []struct {
|
||||
name string
|
||||
args []string
|
||||
}{
|
||||
{name: "detached", args: append(append([]string(nil), base...), "--dry-run", "false")},
|
||||
{name: "explicit", args: append(append([]string(nil), base...), "--dry-run=false")},
|
||||
}
|
||||
var wantAttempts []any
|
||||
for _, test := range falseCases {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, _, attempts, err := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if err == nil || !strings.Contains(err.Error(), "dry-run reached the injected command runner") {
|
||||
t.Fatalf("dry-run=false dispatch error = %v", err)
|
||||
}
|
||||
if len(attempts) != 1 || attempts[0].DryRun {
|
||||
t.Fatalf("dry-run=false attempts = %#v", attempts)
|
||||
}
|
||||
if test.name == "detached" && !hasBooleanCorrection(ctx, "--dry-run false", "--dry-run=false") {
|
||||
t.Fatalf("detached false correction = %#v", ctx)
|
||||
}
|
||||
serialized := []any{attempts[0].CanonicalProduct, attempts[0].Tool, attempts[0].Params, attempts[0].DryRun}
|
||||
if wantAttempts == nil {
|
||||
wantAttempts = serialized
|
||||
} else if !reflect.DeepEqual(serialized, wantAttempts) {
|
||||
t.Fatalf("detached and explicit false dispatch differ\nwant=%#v\ngot=%#v", wantAttempts, serialized)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContradictoryBooleanValuesFailBeforeDestructiveDispatch(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"mail", "thread", "trash",
|
||||
"--email", "user@example.com", "--id", "conversation-1",
|
||||
"--yes", "true", "--yes=false",
|
||||
)
|
||||
var conflict *pipeline.BoolValueConflictError
|
||||
if !stderrors.As(err, &conflict) {
|
||||
t.Fatalf("conflicting confirmation error = %v, want BoolValueConflictError (ctx=%#v)", err, ctx)
|
||||
}
|
||||
if conflict.Flag != "yes" || !reflect.DeepEqual(conflict.Values, []string{"false", "true"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("conflicting confirmation reached destructive dispatch: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func booleanContractFlag(command *cobra.Command, name string) *pflag.Flag {
|
||||
if command == nil {
|
||||
return nil
|
||||
}
|
||||
if flag := command.Flags().Lookup(name); flag != nil {
|
||||
return flag
|
||||
}
|
||||
return command.InheritedFlags().Lookup(name)
|
||||
}
|
||||
|
||||
func hasBooleanCorrection(ctx *pipeline.Context, original, corrected string) bool {
|
||||
if ctx == nil {
|
||||
return false
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "boolvalue" && correction.Original == original && correction.Corrected == corrected {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const cacheUnsupportedMessage = "dws cache 不再支持:服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。"
|
||||
|
||||
const cacheReplacementHint = "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。"
|
||||
|
||||
type cacheCompatNotice struct {
|
||||
Status string `json:"status"`
|
||||
Command string `json:"command"`
|
||||
@@ -28,24 +32,32 @@ type cacheCompatNotice struct {
|
||||
Replacement string `json:"replacement,omitempty"`
|
||||
}
|
||||
|
||||
// newCacheCommand keeps a visible Deprecated compatibility surface for
|
||||
// historical argv (refresh/status/clean). Behavior is a successful no-op notice.
|
||||
// Skills must not teach this path. Deprecated leaves are excluded from Schema
|
||||
// via cobra.IsAvailableCommand() — do not add schema_command_exclusions entries.
|
||||
func newCacheCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "cache",
|
||||
Short: "服务发现缓存兼容入口(静态端点模式已弃用)",
|
||||
Hidden: true,
|
||||
Short: "不再支持:服务发现缓存兼容入口",
|
||||
Long: "此命令组仅为历史 argv 兼容保留。静态端点模式下无需服务发现缓存;Skill / Agent 请勿引导此路径。",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
return printCacheCompatNotice(cmd, "dws cache")
|
||||
},
|
||||
}
|
||||
for _, name := range []string{"refresh", "status", "clean"} {
|
||||
subName := name
|
||||
sub := &cobra.Command{
|
||||
Use: name,
|
||||
Short: "已弃用:静态端点模式无需服务发现缓存",
|
||||
Use: subName,
|
||||
Short: "不再支持:静态端点模式无需服务发现缓存",
|
||||
Deprecated: "不再支持;" + cacheUnsupportedMessage,
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return printCacheCompatNotice(cmd, name)
|
||||
return printCacheCompatNotice(cmd, "dws cache "+subName)
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(sub)
|
||||
@@ -56,9 +68,9 @@ func newCacheCommand() *cobra.Command {
|
||||
func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
notice := cacheCompatNotice{
|
||||
Status: "deprecated",
|
||||
Command: "dws cache " + command,
|
||||
Message: "服务发现已下线,当前版本使用编译期静态端点目录;dws cache 仅保留为兼容入口,不会刷新端点。",
|
||||
Replacement: "如遇 endpoint_not_resolved,请先执行 dws upgrade 获取包含最新 internal/syncdata 端点的版本;仍失败时检查 internal/syncdata.StaticServers() 是否覆盖目标 product/server。",
|
||||
Command: command,
|
||||
Message: cacheUnsupportedMessage,
|
||||
Replacement: cacheReplacementHint,
|
||||
}
|
||||
format, _ := cmd.Root().PersistentFlags().GetString("format")
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
@@ -66,8 +78,7 @@ func printCacheCompatNotice(cmd *cobra.Command, command string) error {
|
||||
return json.NewEncoder(cmd.OutOrStdout()).Encode(notice)
|
||||
case "pretty":
|
||||
data, _ := json.MarshalIndent(notice, "", " ")
|
||||
var err error
|
||||
_, err = fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
_, err := fmt.Fprintln(cmd.OutOrStdout(), string(data))
|
||||
return err
|
||||
default:
|
||||
_, err := fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n%s\n", notice.Command, notice.Message, notice.Replacement)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageCacheDeprecatedCompatShim(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
group := mustFindCommand(t, root, "cache")
|
||||
if group.Hidden || group.Deprecated == "" || !group.Runnable() {
|
||||
t.Fatalf("cache group contract: hidden=%v deprecated=%q runnable=%v", group.Hidden, group.Deprecated, group.Runnable())
|
||||
}
|
||||
if group.IsAvailableCommand() {
|
||||
t.Fatal("deprecated cache group must not be IsAvailableCommand")
|
||||
}
|
||||
|
||||
for _, leaf := range []string{"refresh", "status", "clean"} {
|
||||
cmd := mustFindCommand(t, root, "cache", leaf)
|
||||
if cmd.Hidden || cmd.Deprecated == "" || !cmd.Runnable() {
|
||||
t.Fatalf("cache %s contract: hidden=%v deprecated=%q runnable=%v", leaf, cmd.Hidden, cmd.Deprecated, cmd.Runnable())
|
||||
}
|
||||
if cmd.IsAvailableCommand() {
|
||||
t.Fatalf("deprecated cache %s must not be IsAvailableCommand", leaf)
|
||||
}
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetErr(&out)
|
||||
cmd.SetArgs([]string{"cache", "refresh", "--format", "json"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("cache refresh compatibility stub: %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`"status":"deprecated"`, `"command":"dws cache refresh"`, "不再支持", "服务发现已下线"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("cache refresh output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"", "json", "pretty", "table"} {
|
||||
var buf bytes.Buffer
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(&buf)
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache status"); err != nil {
|
||||
t.Fatalf("format=%q: %v", format, err)
|
||||
}
|
||||
text := buf.String()
|
||||
if !strings.Contains(text, "不再支持") && !strings.Contains(text, "服务发现已下线") {
|
||||
t.Fatalf("format=%q missing notice:\n%s", format, text)
|
||||
}
|
||||
if format == "" || format == "json" || format == "pretty" {
|
||||
if !strings.Contains(text, `"status":"deprecated"`) && !strings.Contains(text, `"status": "deprecated"`) {
|
||||
t.Fatalf("format=%q missing deprecated JSON status:\n%s", format, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, format := range []string{"pretty", "table"} {
|
||||
parent := &cobra.Command{Use: "dws"}
|
||||
parent.PersistentFlags().String("format", format, "")
|
||||
parent.SetOut(failWriter{})
|
||||
sub := &cobra.Command{Use: "cache"}
|
||||
parent.AddCommand(sub)
|
||||
if err := printCacheCompatNotice(sub, "dws cache clean"); err == nil || !strings.Contains(err.Error(), "write failed") {
|
||||
t.Fatalf("format=%q write failure = %v, want write failed", format, err)
|
||||
}
|
||||
}
|
||||
|
||||
parent := newCacheCommand()
|
||||
var parentOut bytes.Buffer
|
||||
rootWrap := &cobra.Command{Use: "dws"}
|
||||
rootWrap.PersistentFlags().String("format", "json", "")
|
||||
rootWrap.SetOut(&parentOut)
|
||||
rootWrap.AddCommand(parent)
|
||||
parent.SetOut(&parentOut)
|
||||
if err := parent.RunE(parent, nil); err != nil {
|
||||
t.Fatalf("cache parent RunE = %v, want nil success", err)
|
||||
}
|
||||
if !strings.Contains(parentOut.String(), `"command":"dws cache"`) {
|
||||
t.Fatalf("cache parent notice missing command:\n%s", parentOut.String())
|
||||
}
|
||||
|
||||
cache := newCacheCommand()
|
||||
cache.SetOut(&bytes.Buffer{})
|
||||
cache.SetArgs([]string{"status"})
|
||||
if err := cache.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newCatalogCommand(_ cli.CatalogLoader) *cobra.Command {
|
||||
func newCatalogCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "catalog",
|
||||
Short: "查看服务目录 (静态端点模式)",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
//go:embed testdata/shortcut_hallucination_badcases_20260728.json
|
||||
var shortcutHallucinationBadcases20260728JSON []byte
|
||||
|
||||
type shortcutHallucinationReplayCorpus struct {
|
||||
SourceReport string `json:"source_report"`
|
||||
SourceFile string `json:"source_file"`
|
||||
SourceDWSCommit string `json:"source_dws_commit"`
|
||||
Model string `json:"model"`
|
||||
ExpectedCount int `json:"expected_count"`
|
||||
Badcases []shortcutHallucinationReplayBadcase `json:"badcases"`
|
||||
}
|
||||
|
||||
type shortcutHallucinationReplayBadcase struct {
|
||||
ID string `json:"id"`
|
||||
CaseID string `json:"case_id"`
|
||||
Run int `json:"run"`
|
||||
CommandIndex int `json:"command_index"`
|
||||
Turn int `json:"turn"`
|
||||
SourcePath string `json:"source_path"`
|
||||
ExpectedOutcome string `json:"expected_outcome"`
|
||||
Raw string `json:"raw"`
|
||||
OriginalExitCode int `json:"original_exit_code"`
|
||||
IsHelp bool `json:"is_help"`
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationBadcases20260728Replay(t *testing.T) {
|
||||
var corpus shortcutHallucinationReplayCorpus
|
||||
if err := json.Unmarshal(shortcutHallucinationBadcases20260728JSON, &corpus); err != nil {
|
||||
t.Fatalf("decode historical shortcut badcase corpus: %v", err)
|
||||
}
|
||||
if corpus.SourceReport != "param_hallucination_20260728_120943" ||
|
||||
corpus.SourceFile != "raw_dashscope_qwen3_7-max_20260728_120943.json" ||
|
||||
corpus.SourceDWSCommit != "a8e83e5" || corpus.Model != "dashscope/qwen3.7-max" {
|
||||
t.Fatalf("unexpected historical corpus provenance: %#v", corpus)
|
||||
}
|
||||
if corpus.ExpectedCount != 52 || len(corpus.Badcases) != corpus.ExpectedCount {
|
||||
t.Fatalf("historical shortcut badcases = %d, expected_count=%d; want 52", len(corpus.Badcases), corpus.ExpectedCount)
|
||||
}
|
||||
|
||||
wantOutcomeCounts := map[string]int{
|
||||
"rewrite": 8,
|
||||
"ambiguous": 44,
|
||||
}
|
||||
wantSourceCounts := map[string]int{
|
||||
"chat +group-member-list": 1,
|
||||
"chat +group-send-text": 1,
|
||||
"chat +list-group-bots": 1,
|
||||
"chat +list-robot": 1,
|
||||
"chat +list-robots": 1,
|
||||
"chat +members": 3,
|
||||
"chat +message-list": 1,
|
||||
"chat +read-single": 6,
|
||||
"chat +rename-group": 1,
|
||||
"chat +send": 4,
|
||||
"chat +send-by-bot": 3,
|
||||
"chat +send-dm": 2,
|
||||
"chat +send-file": 15,
|
||||
"chat +send-image": 3,
|
||||
"chat +send-media": 2,
|
||||
"chat +send-message": 3,
|
||||
"chat +send-single": 1,
|
||||
"chat +send-text": 2,
|
||||
"chat +send-to": 1,
|
||||
}
|
||||
rewriteTargets := map[string]string{
|
||||
"chat +members": "chat +group-members",
|
||||
"chat +group-member-list": "chat +group-members",
|
||||
"chat +list-group-bots": "chat +chat-bots",
|
||||
"chat +list-robot": "chat +chat-bots",
|
||||
"chat +list-robots": "chat +chat-bots",
|
||||
"chat +rename-group": "chat +chat-update",
|
||||
}
|
||||
|
||||
seenIDs := make(map[string]bool, len(corpus.Badcases))
|
||||
gotOutcomeCounts := make(map[string]int)
|
||||
gotSourceCounts := make(map[string]int)
|
||||
for _, badcase := range corpus.Badcases {
|
||||
badcase := badcase
|
||||
t.Run(badcase.ID, func(t *testing.T) {
|
||||
if badcase.ID == "" || seenIDs[badcase.ID] {
|
||||
t.Fatalf("missing or duplicate historical badcase id %q", badcase.ID)
|
||||
}
|
||||
seenIDs[badcase.ID] = true
|
||||
gotOutcomeCounts[badcase.ExpectedOutcome]++
|
||||
gotSourceCounts[badcase.SourcePath]++
|
||||
|
||||
args := historicalShortcutBadcaseArgv(t, badcase.Raw)
|
||||
if len(args) < 2 || strings.Join(args[:2], " ") != badcase.SourcePath {
|
||||
t.Fatalf("raw argv source = %v, fixture source_path=%q", args, badcase.SourcePath)
|
||||
}
|
||||
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
switch badcase.ExpectedOutcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, rewriteTargets[badcase.SourcePath], ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath)
|
||||
if !ok || entry.Mode != cli.CommandPathFallbackAmbiguous || len(entry.Candidates) < 2 {
|
||||
t.Fatalf("ambiguous fallback table entry = %#v, %v", entry, ok)
|
||||
}
|
||||
case "unknown_shortcut":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "unknown_shortcut")
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("contract-incomplete path unexpectedly entered fallback table: %#v", entry)
|
||||
}
|
||||
default:
|
||||
t.Fatalf("unsupported expected_outcome %q", badcase.ExpectedOutcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if !reflect.DeepEqual(gotOutcomeCounts, wantOutcomeCounts) {
|
||||
t.Errorf("historical outcome counts = %v, want %v", gotOutcomeCounts, wantOutcomeCounts)
|
||||
}
|
||||
if !reflect.DeepEqual(gotSourceCounts, wantSourceCounts) {
|
||||
t.Errorf("historical source counts = %v, want %v", gotSourceCounts, wantSourceCounts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageShortcutHallucinationMerged20260720Replay(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
raw string
|
||||
occurrences int
|
||||
outcome string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
id: "dws_im_v2_0013-search-group",
|
||||
raw: `dws chat +search-group --name "dws测试群02" --format json`,
|
||||
occurrences: 1,
|
||||
outcome: "official_alias",
|
||||
target: "chat +chat-search",
|
||||
},
|
||||
{
|
||||
id: "dws_oa_0017-list-processes",
|
||||
raw: `dws oa +list-processes --format json --limit 50`,
|
||||
occurrences: 2,
|
||||
outcome: "ambiguous",
|
||||
},
|
||||
}
|
||||
totalOccurrences := 0
|
||||
for _, test := range tests {
|
||||
test := test
|
||||
t.Run(test.id, func(t *testing.T) {
|
||||
totalOccurrences += test.occurrences
|
||||
args := historicalShortcutBadcaseArgv(t, test.raw)
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
badcase := shortcutHallucinationReplayBadcase{
|
||||
ID: test.id,
|
||||
SourcePath: strings.Join(args[:2], " "),
|
||||
ExpectedOutcome: test.outcome,
|
||||
Raw: test.raw,
|
||||
}
|
||||
switch test.outcome {
|
||||
case "rewrite":
|
||||
assertHistoricalShortcutRewrite(t, badcase, test.target, ctx, err)
|
||||
case "official_alias":
|
||||
assertHistoricalOfficialAlias(t, badcase, test.target, ctx, err)
|
||||
case "ambiguous":
|
||||
assertHistoricalShortcutReason(t, badcase, ctx, err, "ambiguous_command_fallback")
|
||||
default:
|
||||
t.Fatalf("unsupported merged expected outcome %q", test.outcome)
|
||||
}
|
||||
})
|
||||
}
|
||||
if totalOccurrences != 3 {
|
||||
t.Fatalf("merged shortcut hallucination occurrences = %d, want 3", totalOccurrences)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalOfficialAlias(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if err != nil {
|
||||
t.Fatalf("historical official alias %q returned error: %v", badcase.SourcePath, err)
|
||||
}
|
||||
if wantTarget == "" || ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical official alias context = %#v; want command dws %s", ctx, wantTarget)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("historical official alias received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if entry, ok := cli.LookupCommandPathFallback(badcase.SourcePath); ok {
|
||||
t.Fatalf("official alias unexpectedly remains in fallback table: %#v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func historicalShortcutBadcaseArgv(t *testing.T, raw string) []string {
|
||||
t.Helper()
|
||||
command := strings.TrimSpace(raw)
|
||||
for _, suffix := range []string{" 2>&1 | head -50", " 2>&1"} {
|
||||
command = strings.TrimSuffix(command, suffix)
|
||||
}
|
||||
argv, err := cli.ParseAgentExampleArgv(command)
|
||||
if err != nil {
|
||||
t.Fatalf("parse historical raw command %q without a shell: %v", raw, err)
|
||||
}
|
||||
if len(argv) < 3 || argv[0] != "dws" {
|
||||
t.Fatalf("historical raw command did not produce dws argv: %q => %v", raw, argv)
|
||||
}
|
||||
return append([]string(nil), argv[1:]...)
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutRewrite(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
wantTarget string,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
) {
|
||||
t.Helper()
|
||||
if wantTarget == "" {
|
||||
t.Fatalf("historical rewrite %q has no reviewed target", badcase.SourcePath)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+wantTarget {
|
||||
t.Fatalf("historical rewrite context = %#v, error=%v; want command dws %s", ctx, err, wantTarget)
|
||||
}
|
||||
found := false
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" && correction.Original == badcase.SourcePath && correction.Corrected == wantTarget {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("historical rewrite corrections = %#v; want %q -> %q", ctx.Corrections, badcase.SourcePath, wantTarget)
|
||||
}
|
||||
wantPrefix := strings.Fields(wantTarget)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("historical rewrite argv = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
if reason := structuredShortcutReplayReason(err); reason == "unknown_shortcut" || reason == "ambiguous_command_fallback" {
|
||||
t.Fatalf("historical rewrite returned command-resolution reason %q: %v", reason, err)
|
||||
}
|
||||
}
|
||||
|
||||
func assertHistoricalShortcutReason(
|
||||
t *testing.T,
|
||||
badcase shortcutHallucinationReplayBadcase,
|
||||
ctx *pipeline.Context,
|
||||
err error,
|
||||
wantReason string,
|
||||
) {
|
||||
t.Helper()
|
||||
if ctx == nil {
|
||||
t.Fatalf("historical %s badcase returned nil context: %v", wantReason, err)
|
||||
}
|
||||
if got := structuredShortcutReplayReason(err); got != wantReason {
|
||||
t.Fatalf("historical badcase %q reason = %q, error=%v; want %q (original exit=%d help=%v)", badcase.Raw, got, err, wantReason, badcase.OriginalExitCode, badcase.IsHelp)
|
||||
}
|
||||
if strings.Contains(strings.ToLower(fmt.Sprint(err)), "unknown flag") {
|
||||
t.Fatalf("historical badcase %q regressed to unknown flag: %v", badcase.Raw, err)
|
||||
}
|
||||
}
|
||||
|
||||
func structuredShortcutReplayReason(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(err, &structured) {
|
||||
return structured.Reason
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,603 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
runtimeShortcutFallbackChildEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CHILD"
|
||||
runtimeShortcutFallbackConfigEnv = "DWS_RUNTIME_SHORTCUT_FALLBACK_CONFIG_DIR"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback(t *testing.T) {
|
||||
if os.Getenv(runtimeShortcutFallbackChildEnv) == "1" {
|
||||
t.Setenv("DWS_CONFIG_DIR", os.Getenv(runtimeShortcutFallbackConfigEnv))
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommandWithEngine(context.Background(), engine)
|
||||
runtimeCommand := exactAppCommand(root, "chat +members")
|
||||
if runtimeCommand == nil || !runtimeCommand.Runnable() || cmdutil.IsHintOnlyCommand(runtimeCommand) {
|
||||
var userDefined []string
|
||||
for _, candidate := range shortcut.All() {
|
||||
if candidate.UserDefined {
|
||||
userDefined = append(userDefined, candidate.Service+" "+candidate.Command)
|
||||
}
|
||||
}
|
||||
t.Fatalf("runtime shortcut was not mounted as an executable command: command=%#v user_defined=%v config=%q", runtimeCommand, userDefined, os.Getenv("DWS_CONFIG_DIR"))
|
||||
}
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+members", "--sentinel", "fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +members" {
|
||||
t.Fatalf("runtime shortcut context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("runtime shortcut execute: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
configDir := t.TempDir()
|
||||
shortcutDir := filepath.Join(configDir, "shortcuts")
|
||||
if err := os.MkdirAll(shortcutDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition := `version: 1
|
||||
service: chat
|
||||
command: "+members"
|
||||
product: chat
|
||||
description: runtime members fixture
|
||||
execute:
|
||||
tool: fixture_user_members
|
||||
bind:
|
||||
sentinel: "${sentinel}"
|
||||
flags:
|
||||
- name: sentinel
|
||||
type: string
|
||||
required: true
|
||||
desc: fixture sentinel
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(shortcutDir, "chat.members.yaml"), []byte(definition), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
command := exec.Command(os.Args[0], "-test.run=^TestCrossPlatformCoverageRuntimeUserShortcutTakesPrecedenceOverReviewedFallback$", "-test.count=1")
|
||||
command.Env = append(os.Environ(), runtimeShortcutFallbackConfigEnv+"="+configDir, runtimeShortcutFallbackChildEnv+"=1")
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("runtime shortcut child failed: %v\n%s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
if !strings.Contains(string(output), `"_tool": "fixture_user_members"`) {
|
||||
t.Fatalf("runtime shortcut child used the wrong command:\n%s", strings.TrimSpace(string(output)))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedCommandFallbacksReachCanonicalDryRunPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "group search query",
|
||||
args: []string{"chat", "+group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("fallback execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("fallback crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("fallback preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("fallback context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("fallback correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialCommandAliasesBypassFallbackAndReachEquivalentPayload(t *testing.T) {
|
||||
_, canonicalQuery, canonicalQueryAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalQueryAttempts) != 0 {
|
||||
t.Fatalf("canonical query preview = %#v, attempts=%v, error=%v", canonicalQuery, canonicalQueryAttempts, err)
|
||||
}
|
||||
_, canonicalKeyword, canonicalKeywordAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+chat-search", "--keyword", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(canonicalKeywordAttempts) != 0 {
|
||||
t.Fatalf("canonical keyword preview = %#v, attempts=%v, error=%v", canonicalKeyword, canonicalKeywordAttempts, err)
|
||||
}
|
||||
_, nativeCanonical, nativeCanonicalAttempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "search", "--query", "project", "--dry-run",
|
||||
)
|
||||
if err != nil || len(nativeCanonicalAttempts) != 0 {
|
||||
t.Fatalf("native canonical preview = %#v, attempts=%v, error=%v", nativeCanonical, nativeCanonicalAttempts, err)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
wantCommand string
|
||||
wantPreview paramAliasDryRunPreview
|
||||
}{
|
||||
{
|
||||
name: "search group shortcut alias",
|
||||
args: []string{"chat", "+search-group", "--keyword", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalKeyword,
|
||||
},
|
||||
{
|
||||
name: "chat group search shortcut alias",
|
||||
args: []string{"chat", "+chat-group-search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat +chat-search",
|
||||
wantPreview: canonicalQuery,
|
||||
},
|
||||
{
|
||||
name: "hidden native compatibility leaf",
|
||||
args: []string{"chat", "group", "search", "--query", "project", "--dry-run"},
|
||||
wantCommand: "dws chat group search",
|
||||
wantPreview: nativeCanonical,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx, preview, attempts, executeErr := executeParamAliasDryRunE2E(t, test.args...)
|
||||
if executeErr != nil {
|
||||
t.Fatalf("official alias execute error = %v", executeErr)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("official alias crossed dry-run dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !reflect.DeepEqual(preview, test.wantPreview) {
|
||||
t.Fatalf("official alias preview = %#v, want canonical %#v", preview, test.wantPreview)
|
||||
}
|
||||
if ctx == nil || ctx.Command != test.wantCommand {
|
||||
t.Fatalf("official alias context = %#v, want command %q", ctx, test.wantCommand)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedReadFallbacksResolveCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
target string
|
||||
}{
|
||||
{
|
||||
name: "members",
|
||||
args: []string{"chat", "+members", "--group", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "group member list",
|
||||
args: []string{"chat", "+group-member-list", "--group-name", "Fixture Group"},
|
||||
target: "chat +group-members",
|
||||
},
|
||||
{
|
||||
name: "list group bots",
|
||||
args: []string{"chat", "+list-group-bots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robot",
|
||||
args: []string{"chat", "+list-robot", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "list robots",
|
||||
args: []string{"chat", "+list-robots", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "bot list",
|
||||
args: []string{"chat", "+bot-list", "--group", "cid-fixture"},
|
||||
target: "chat +chat-bots",
|
||||
},
|
||||
{
|
||||
name: "conversation detail",
|
||||
args: []string{"chat", "+conversation-detail", "--group", "cid-fixture"},
|
||||
target: "chat +conversation-info",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws "+test.target || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("read fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := strings.Fields(test.target)
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("read fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageOfficialGroupMembersAliasBypassesCommandFallback(t *testing.T) {
|
||||
args := []string{"chat", "+chat-group-members", "--conversation-id", "cid-fixture", "--member-types", "user"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-members-list" {
|
||||
t.Fatalf("official group-members alias context = %#v", ctx)
|
||||
}
|
||||
assertNoCommandPathFallbackCorrection(t, ctx)
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedRenameFallbackResolvesCanonicalLeafBeforeParameterValidation(t *testing.T) {
|
||||
args := []string{"chat", "+rename-group", "--id", "cid-fixture", "--name", "Fixture Group"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-update" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("rename fallback context = %#v", ctx)
|
||||
}
|
||||
wantPrefix := []string{"chat", "+chat-update"}
|
||||
if len(ctx.Args) < len(wantPrefix) || !reflect.DeepEqual(ctx.Args[:len(wantPrefix)], wantPrefix) {
|
||||
t.Fatalf("rename fallback args = %v, want prefix %v", ctx.Args, wantPrefix)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedDocHistorySaveFallbacksPreserveArguments(t *testing.T) {
|
||||
sources := []string{"+create-version", "+save-version", "+snapshot", "+version-create"}
|
||||
for _, source := range sources {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
args := []string{"doc", source, "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws doc +history-save" || len(ctx.Corrections) == 0 {
|
||||
t.Fatalf("history-save fallback context = %#v", ctx)
|
||||
}
|
||||
wantArgs := []string{"doc", "+history-save", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
if !reflect.DeepEqual(ctx.Args, wantArgs) {
|
||||
t.Fatalf("history-save fallback args = %v, want %v", ctx.Args, wantArgs)
|
||||
}
|
||||
correction := ctx.Corrections[0]
|
||||
if correction.Handler != "command-path-fallback" || correction.Kind != "reviewed-fallback" {
|
||||
t.Fatalf("history-save correction = %#v", correction)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocExportPDFRemainsUnknownShortcut(t *testing.T) {
|
||||
if entry, ok := cli.LookupCommandPathFallback("doc +export-pdf"); ok {
|
||||
t.Fatalf("+export-pdf must not receive a path-only fallback: %#v", entry)
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"doc", "+export-pdf", "--node", "node-fixture", "--mock", "--format", "json"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if ctx == nil {
|
||||
t.Fatal("+export-pdf returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "unknown_shortcut" {
|
||||
t.Fatalf("+export-pdf preparse error = %T %#v", err, err)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("+export-pdf unexpectedly received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedAmbiguousCommandFallbackNeverDispatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
candidates []string
|
||||
}{
|
||||
{path: "chat +group-send-text", candidates: []string{"chat +send-to-group", "chat +messages-send"}},
|
||||
{path: "chat +message-list", candidates: []string{"chat +chat-messages", "chat +messages-list-direct", "chat +search-msg", "chat +unread-chats"}},
|
||||
{path: "chat +read-single", candidates: []string{"chat +messages-list-direct", "chat +chat-messages"}},
|
||||
{path: "chat +send", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-by-bot", candidates: []string{"chat +messages-send", "chat message send-by-bot"}},
|
||||
{path: "chat +send-dm", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-message", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-single", candidates: []string{"chat +dm", "chat +messages-send"}},
|
||||
{path: "chat +send-text", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-to", candidates: []string{"chat +messages-send", "chat +dm", "chat +send-to-group"}},
|
||||
{path: "chat +send-file", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-image", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{path: "chat +send-media", candidates: []string{"chat +messages-send", "chat message send"}},
|
||||
{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"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.path, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := append(strings.Fields(test.path), "--format", "json")
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if ctx == nil {
|
||||
t.Fatal("ambiguous command fallback returned nil context")
|
||||
}
|
||||
var structured *apperrors.Error
|
||||
if !stderrors.As(err, &structured) || structured.Reason != "ambiguous_command_fallback" || structured.ExitCode() != 3 {
|
||||
t.Fatalf("ambiguous error = %T %#v", err, err)
|
||||
}
|
||||
if len(structured.Actions) != len(test.candidates) || len(caller.calls) != 0 {
|
||||
t.Fatalf("ambiguous actions=%v calls=%#v", structured.Actions, caller.calls)
|
||||
}
|
||||
for _, candidate := range test.candidates {
|
||||
if !strings.Contains(structured.Hint, "dws "+candidate) {
|
||||
t.Errorf("ambiguous hint %q missing candidate %q", structured.Hint, candidate)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCanonicalShortcutBadFlagDoesNotEnterCommandFallback(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
args := []string{"chat", "+chat-search", "--definitely-not-a-real-flag", "project"}
|
||||
root.SetArgs(args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" {
|
||||
t.Fatalf("canonical context = %#v", ctx)
|
||||
}
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("canonical command received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("canonical bad flag error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRewrittenShortcutStillUsesCanonicalParameterErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing required query",
|
||||
args: []string{"chat", "+group-search", "--dry-run"},
|
||||
want: "请至少指定 --query、--keyword 之一",
|
||||
},
|
||||
{
|
||||
name: "unknown canonical flag",
|
||||
args: []string{"chat", "+group-search", "--definitely-not-a-real-flag", "project"},
|
||||
want: "unknown flag",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
root := NewSchemaSourceRootCommand()
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(test.args)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), test.args)
|
||||
if err != nil {
|
||||
t.Fatalf("preparse error = %v", err)
|
||||
}
|
||||
if ctx == nil || ctx.Command != "dws chat +chat-search" || len(ctx.Corrections) == 0 ||
|
||||
ctx.Corrections[0].Handler != "command-path-fallback" {
|
||||
t.Fatalf("rewritten context = %#v", ctx)
|
||||
}
|
||||
if err := root.Execute(); err == nil || !strings.Contains(err.Error(), test.want) {
|
||||
t.Fatalf("canonical parameter error = %v, want containing %q", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+bot-list": true,
|
||||
"+conversation-detail": true,
|
||||
"+conversation-category-list": true,
|
||||
"+conversation-group-list": true,
|
||||
"+list-my-groups": true,
|
||||
"+group-search": true,
|
||||
"+members": true,
|
||||
"+group-member-list": true,
|
||||
"+list-group-bots": true,
|
||||
"+list-robot": true,
|
||||
"+list-robots": true,
|
||||
"+message-list": true,
|
||||
"+read-single": true,
|
||||
"+rename-group": true,
|
||||
"+send": true,
|
||||
"+send-by-bot": true,
|
||||
"+send-dm": true,
|
||||
"+send-message": true,
|
||||
"+send-single": true,
|
||||
"+send-text": true,
|
||||
"+send-to": true,
|
||||
"+send-file": true,
|
||||
"+send-image": true,
|
||||
"+send-media": true,
|
||||
"+group-send-text": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
chat := exactAppCommand(root, "chat")
|
||||
if chat == nil {
|
||||
t.Fatal("chat command missing")
|
||||
}
|
||||
for _, child := range chat.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("fallback name %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("fallback name %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("chat " + path); ok {
|
||||
t.Fatalf("fallback path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "chat" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("fallback name %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
if _, ok := cli.ResolveMeta("oa +list-processes"); ok {
|
||||
t.Fatal("fallback path oa +list-processes leaked into embedded Schema")
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "oa" && declared.Command == "+list-processes" {
|
||||
t.Fatal("fallback name +list-processes leaked into shortcut catalog")
|
||||
}
|
||||
}
|
||||
|
||||
group := exactAppCommand(root, "chat group")
|
||||
searchCompatibility := exactAppCommand(root, "chat group search")
|
||||
if group == nil || searchCompatibility == nil || !searchCompatibility.Hidden || cmdutil.IsHintOnlyCommand(searchCompatibility) {
|
||||
t.Fatalf("chat group search must be a hidden executable compatibility leaf: group=%v search=%v", group, searchCompatibility)
|
||||
}
|
||||
var help bytes.Buffer
|
||||
group.SetOut(&help)
|
||||
if err := group.Help(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(help.String(), "\n search ") {
|
||||
t.Fatalf("hidden fallback source leaked into group help:\n%s", help.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageDocCommandFallbackNamesStayOutOfHelpSchemaAndShortcutCatalog(t *testing.T) {
|
||||
invalidShortcuts := map[string]bool{
|
||||
"+create-version": true,
|
||||
"+save-version": true,
|
||||
"+snapshot": true,
|
||||
"+version-create": true,
|
||||
"+export-pdf": true,
|
||||
}
|
||||
root := NewSchemaSourceRootCommand()
|
||||
doc := exactAppCommand(root, "doc")
|
||||
if doc == nil {
|
||||
t.Fatal("doc command missing")
|
||||
}
|
||||
for _, child := range doc.Commands() {
|
||||
if invalidShortcuts[child.Name()] {
|
||||
t.Fatalf("invalid shortcut %q became a real command", child.Name())
|
||||
}
|
||||
for _, alias := range child.Aliases {
|
||||
if invalidShortcuts[alias] {
|
||||
t.Fatalf("invalid shortcut %q became a Cobra alias", alias)
|
||||
}
|
||||
}
|
||||
}
|
||||
for path := range invalidShortcuts {
|
||||
if _, ok := cli.ResolveMeta("doc " + path); ok {
|
||||
t.Fatalf("invalid path %q leaked into embedded Schema", path)
|
||||
}
|
||||
}
|
||||
for _, declared := range shortcut.All() {
|
||||
if declared.Service == "doc" && invalidShortcuts[declared.Command] {
|
||||
t.Fatalf("invalid shortcut %q leaked into shortcut catalog", declared.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoCommandPathFallbackCorrection(t *testing.T, ctx *pipeline.Context) {
|
||||
t.Helper()
|
||||
for _, correction := range ctx.Corrections {
|
||||
if correction.Handler == "command-path-fallback" {
|
||||
t.Fatalf("official command unexpectedly received fallback correction: %#v", ctx.Corrections)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func exactAppCommand(root *cobra.Command, path string) *cobra.Command {
|
||||
current := root
|
||||
parts := strings.Fields(path)
|
||||
if len(parts) > 0 && parts[0] == root.Name() {
|
||||
parts = parts[1:]
|
||||
}
|
||||
for _, part := range parts {
|
||||
var next *cobra.Command
|
||||
for _, child := range current.Commands() {
|
||||
if child.Name() == part {
|
||||
next = child
|
||||
break
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil
|
||||
}
|
||||
current = next
|
||||
}
|
||||
return current
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import (
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
eventbus "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
@@ -33,7 +32,6 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pat"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/plugin"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/recovery"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/safety"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
upgradepkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/upgrade"
|
||||
@@ -228,117 +226,6 @@ func TestCrossPlatformCoverageDocDownloadPureCoverage(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryPureCoverage(t *testing.T) {
|
||||
if _, err := decodeRecoveryAttempts(nil, nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("null"), nil, "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage(`[{"command_summary":"one"}]`), nil, "", ""); err != nil || len(got) != 1 {
|
||||
t.Fatalf("array attempts = %#v %v", got, err)
|
||||
}
|
||||
if _, err := decodeRecoveryAttempts(json.RawMessage("{"), nil, "", ""); err == nil {
|
||||
t.Fatal("malformed attempts succeeded")
|
||||
}
|
||||
if got, err := decodeRecoveryAttempts(json.RawMessage("2"), []string{"a"}, "ok", ""); err != nil || len(got) != 2 {
|
||||
t.Fatalf("legacy attempts = %#v %v", got, err)
|
||||
}
|
||||
if legacyRecoveryAttempts(0, nil, "", "") != nil || len(legacyRecoveryAttempts(1, nil, "", "")) != 1 {
|
||||
t.Fatal("legacy attempts edge mismatch")
|
||||
}
|
||||
|
||||
for _, args := range [][]string{
|
||||
{"dws", "--debug", "doc", "get", "--node", "n"},
|
||||
{"dws", "--format=json", "doc", "--", "ignored"},
|
||||
{"dws", "--unknown", "value", "doc"},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = args
|
||||
_ = currentCommandPath()
|
||||
os.Args = old
|
||||
}
|
||||
for _, inv := range []executor.Invocation{
|
||||
{LegacyPath: "legacy path"},
|
||||
{CanonicalProduct: "doc", Tool: "get"},
|
||||
{CanonicalProduct: "doc"},
|
||||
{},
|
||||
} {
|
||||
old := os.Args
|
||||
os.Args = []string{"dws"}
|
||||
_ = runtimeCommandPath(inv)
|
||||
os.Args = old
|
||||
}
|
||||
if cloneRecoveryArgs(nil) != nil {
|
||||
t.Fatal("empty recovery args should clone to nil")
|
||||
}
|
||||
original := map[string]any{"x": 1}
|
||||
clone := cloneRecoveryArgs(original)
|
||||
clone["x"] = 2
|
||||
if original["x"] != 1 {
|
||||
t.Fatal("recovery args were not cloned")
|
||||
}
|
||||
|
||||
if got, _ := (*recoveryRuntime)(nil).Search(context.Background(), "query", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("nil recovery search = %#v", got)
|
||||
}
|
||||
if got, _ := (&recoveryRuntime{}).Search(context.Background(), " ", recovery.RecoveryContext{}); got.DocSearch.Status != "skipped" {
|
||||
t.Fatalf("blank recovery search = %#v", got)
|
||||
}
|
||||
if _, err := (*recoveryRuntime)(nil).CallToolDirect(context.Background(), "x", "y", nil); err == nil {
|
||||
t.Fatal("nil recovery runtime call succeeded")
|
||||
}
|
||||
if _, err := (&recoveryRuntime{}).resolveEndpoint(context.Background(), "missing", "tool"); err == nil {
|
||||
t.Fatal("missing recovery endpoint succeeded")
|
||||
}
|
||||
loaderErr := errors.New("catalog")
|
||||
runtime := &recoveryRuntime{loader: cli.CatalogLoaderFrom(cli.Catalog{}, loaderErr)}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "missing", "tool"); !errors.Is(err, loaderErr) {
|
||||
t.Fatalf("catalog recovery error = %v", err)
|
||||
}
|
||||
runtime.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{{ID: "empty"}, {ID: "ok", Endpoint: " https://catalog.test "}}}}
|
||||
if _, err := runtime.resolveEndpoint(context.Background(), "empty", "tool"); err == nil {
|
||||
t.Fatal("empty catalog endpoint succeeded")
|
||||
}
|
||||
if got, err := runtime.resolveEndpoint(context.Background(), "ok", "tool"); err != nil || got != "https://catalog.test" {
|
||||
t.Fatalf("catalog endpoint = %q %v", got, err)
|
||||
}
|
||||
if recoveryRuntimeToken(nil) != "" || recoveryRuntimeToken(&GlobalFlags{Token: " token "}) != "token" {
|
||||
t.Fatal("recovery token mismatch")
|
||||
}
|
||||
if toRecoveryToolResponse(nil) != nil {
|
||||
t.Fatal("nil recovery response should stay nil")
|
||||
}
|
||||
response := toRecoveryToolResponse(&transport.ToolCallResult{IsError: true, Blocks: []transport.ContentBlock{{Type: "text", Text: "body"}}})
|
||||
if response == nil || !response.IsError || len(response.Content) != 1 {
|
||||
t.Fatalf("recovery response = %#v", response)
|
||||
}
|
||||
|
||||
items := []any{map[string]any{"title": "A", "url": "u", "desc": "d"}, "skip", map[string]any{}}
|
||||
for _, payload := range []map[string]any{
|
||||
nil,
|
||||
{"items": items},
|
||||
{"data": map[string]any{"items": items}},
|
||||
{"result": map[string]any{"items": items}},
|
||||
} {
|
||||
_ = parseDocSearchItemsFromMap(payload)
|
||||
}
|
||||
if toDocSearchItems("bad") != nil {
|
||||
t.Fatal("non-list doc items accepted")
|
||||
}
|
||||
result := &transport.ToolCallResult{Content: map[string]any{}, Blocks: []transport.ContentBlock{{Text: "{"}, {Text: `{"items":[{"title":"B"}]}`}}}
|
||||
if got := parseDocSearchItems(result); len(got) != 1 {
|
||||
t.Fatalf("block doc items = %#v", got)
|
||||
}
|
||||
if parseDocSearchItems(nil) != nil {
|
||||
t.Fatal("nil doc result should be nil")
|
||||
}
|
||||
searchItems := []recovery.DocSearchItem{{Title: "query", URL: "u"}, {Title: "other"}, {Title: "third"}, {Title: "fourth"}}
|
||||
if len(rerankDocSearchHits("query", recovery.RecoveryContext{ToolName: "tool", CommandPath: []string{"doc"}}, searchItems)) != 3 || rerankDocSearchHits("", recovery.RecoveryContext{}, nil) != nil {
|
||||
t.Fatal("doc search reranking mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSmallAppRegistryAndRootCoverage(t *testing.T) {
|
||||
RegisterPluginAuth("coverage-registry", &PluginAuth{Token: "token"})
|
||||
t.Cleanup(func() {
|
||||
@@ -451,11 +338,14 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
},
|
||||
}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{CLI: mcptypes.CLIOverlay{Skip: true}}, server, {CLI: mcptypes.CLIOverlay{ID: "empty"}}})
|
||||
if got, ok := directRuntimeToolEndpoint("tool"); !ok || got != "https://one.test" {
|
||||
if got, ok := directRuntimeEndpoint("unknown", "tool"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeToolEndpoint(" "); ok {
|
||||
t.Fatal("blank tool endpoint resolved")
|
||||
if got, ok := directRuntimeEndpoint("unknown", "override"); !ok || got != "https://one.test" {
|
||||
t.Fatalf("tool override endpoint = %q %v", got, ok)
|
||||
}
|
||||
if _, ok := directRuntimeEndpoint("unknown", "skip"); ok {
|
||||
t.Fatal("server-override tool endpoint resolved")
|
||||
}
|
||||
for _, id := range []string{"one", "cmd", "alias"} {
|
||||
if got, ok := directRuntimeEndpoint(id, ""); !ok || got != "https://one.test" {
|
||||
@@ -469,11 +359,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
t.Fatalf("direct runtime IDs = %#v", ids)
|
||||
}
|
||||
|
||||
t.Setenv(cli.CatalogFixtureEnv, "fixture")
|
||||
if shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) {
|
||||
t.Fatal("fixture should disable direct runtime")
|
||||
}
|
||||
t.Setenv(cli.CatalogFixtureEnv, "")
|
||||
if !shouldUseDirectRuntime(executor.Invocation{Kind: "helper_invocation"}) || !shouldUseDirectRuntime(executor.Invocation{Kind: "compat_invocation"}) || shouldUseDirectRuntime(executor.Invocation{}) {
|
||||
t.Fatal("direct runtime kind mismatch")
|
||||
}
|
||||
@@ -516,59 +401,6 @@ func TestCrossPlatformCoverageDirectRuntimeCoverage(t *testing.T) {
|
||||
_ = defaultPATMCPEndpoint()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryLoadExecutionCoverage(t *testing.T) {
|
||||
if _, err := loadRecoveryExecution(filepath.Join(t.TempDir(), "missing")); err == nil {
|
||||
t.Fatal("missing recovery execution succeeded")
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "execution.json")
|
||||
if err := os.WriteFile(path, []byte("{"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadRecoveryExecution(path); err == nil {
|
||||
t.Fatal("malformed recovery execution succeeded")
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"legacy": `{"action":" one ","attempt":2,"result":" ok ","error":" bad "}`,
|
||||
"modern": `{"actions":["one"],"attempts":[{"command_summary":"one"}],"error_summary":"bad"}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := loadRecoveryExecution(path); err != nil || len(got.Actions) != 1 || len(got.Attempts) == 0 {
|
||||
t.Fatalf("loaded execution = %#v %v", got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRecoveryRuntimeHTTP(t *testing.T) {
|
||||
var result map[string]any = map[string]any{"content": []map[string]any{{"type": "text", "text": `{"items":[{"title":"query result","url":"u"}]}`}}}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
ID int `json:"id"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&req)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
|
||||
}))
|
||||
defer server.Close()
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: server.URL, CLI: mcptypes.CLIOverlay{ID: "devdoc", Tools: []mcptypes.CLITool{{Name: "search_open_platform_docs_rag"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
runtime := &recoveryRuntime{transport: transport.NewClient(server.Client()), flags: &GlobalFlags{Token: "token"}}
|
||||
got, err := runtime.Search(context.Background(), "query", recovery.RecoveryContext{ToolName: "search"})
|
||||
if err != nil || got.DocSearch.Status != "success" || len(got.KBHits) == 0 {
|
||||
t.Fatalf("recovery search = %#v %v", got, err)
|
||||
}
|
||||
result = map[string]any{"isError": true, "content": []map[string]any{{"type": "text", "text": "failed"}}}
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery MCP error succeeded")
|
||||
}
|
||||
server.Close()
|
||||
if _, err := runtime.CallToolDirect(context.Background(), "devdoc", "search_open_platform_docs_rag", nil); err == nil {
|
||||
t.Fatal("recovery network error succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventCommandPureCoverage(t *testing.T) {
|
||||
oldEdition := edition.Get()
|
||||
t.Cleanup(func() { edition.Override(oldEdition) })
|
||||
@@ -765,7 +597,7 @@ func TestCrossPlatformCoverageVersionCacheCompletionCoverage(t *testing.T) {
|
||||
child := &cobra.Command{Use: "child"}
|
||||
root.AddCommand(child)
|
||||
child.SetOut(io.Discard)
|
||||
if err := printCacheCompatNotice(child, "status"); err != nil {
|
||||
if err := printCacheCompatNotice(child, "dws cache status"); err != nil {
|
||||
t.Fatalf("cache %s: %v", format, err)
|
||||
}
|
||||
root.RemoveCommand(child)
|
||||
@@ -798,7 +630,6 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
r.transport = transport.NewClient(nil)
|
||||
r.loader = cli.StaticLoader{}
|
||||
r.globalFlags = &GlobalFlags{Mock: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["content"] == nil {
|
||||
t.Fatalf("mock route = %#v %v", got, err)
|
||||
@@ -809,18 +640,16 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
}
|
||||
t.Setenv("DINGTALK_PRODUCT_MCP_URL", "")
|
||||
|
||||
// Dry-run is an execution barrier enforced by Run before endpoint
|
||||
// resolution, so a dry-run invocation returns a local preview and never
|
||||
// reaches handleCatalogMiss or the fallback runner.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
r.loader = cli.CatalogLoaderFrom(cli.Catalog{}, errors.New("load failure"))
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "load failure") {
|
||||
t.Fatalf("catalog failure = %v", err)
|
||||
}
|
||||
r.loader = cli.StaticLoader{}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("dry catalog miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
if got, err := r.Run(context.Background(), inv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("dry endpoint miss = %#v %v", got, err)
|
||||
}
|
||||
r.globalFlags = &GlobalFlags{}
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil {
|
||||
t.Fatal("catalog miss succeeded")
|
||||
if _, err := r.runSingle(context.Background(), inv, false); err == nil || !strings.Contains(err.Error(), "no dynamic endpoint registered for product or tool") {
|
||||
t.Fatalf("endpoint miss = %v", err)
|
||||
}
|
||||
devInv := inv
|
||||
devInv.CanonicalProduct = devappProductID
|
||||
@@ -828,29 +657,20 @@ func TestCrossPlatformCoverageRuntimeRunnerRoutingCoverage(t *testing.T) {
|
||||
t.Fatal("devapp catalog miss succeeded")
|
||||
}
|
||||
|
||||
product := cli.CanonicalProduct{ID: "product", Endpoint: "https://catalog.test", Tools: []cli.ToolDescriptor{{RPCName: "tool"}}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://catalog.test" {
|
||||
t.Fatalf("catalog dry route = %#v %v", got, err)
|
||||
}
|
||||
product.Tools = nil
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://direct.test", CLI: mcptypes.CLIOverlay{ID: "product", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
t.Cleanup(func() { SetDynamicServers(nil) })
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("undeclared tool direct route = %#v %v", got, err)
|
||||
directInv := inv
|
||||
directInv.Kind = "helper_invocation"
|
||||
directInv.DryRun = true
|
||||
if got, err := r.runSingle(context.Background(), directInv, false); err != nil || got.Response["dry_run"] != true || got.Response["endpoint"] != "https://direct.test" {
|
||||
t.Fatalf("direct runtime route = %#v %v", got, err)
|
||||
}
|
||||
SetDynamicServers(nil)
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["fallback"] != true || !fallback.last.DryRun {
|
||||
t.Fatalf("undeclared tool dry miss = %#v %v (invocation %#v)", got, err, fallback.last)
|
||||
}
|
||||
|
||||
SetDynamicServers([]mcptypes.ServerDescriptor{{Endpoint: "https://owner.test", CLI: mcptypes.CLIOverlay{ID: "owner", Tools: []mcptypes.CLITool{{Name: "tool"}}}}})
|
||||
product.Tools = []cli.ToolDescriptor{{RPCName: "tool"}}
|
||||
r.loader = cli.StaticLoader{Catalog: cli.Catalog{Products: []cli.CanonicalProduct{product}}}
|
||||
if got, err := r.runSingle(context.Background(), inv, false); err != nil || got.Response["endpoint"] != "https://owner.test" {
|
||||
t.Fatalf("tool owner correction = %#v %v", got, err)
|
||||
// With no dynamic endpoint registered, a dry-run helper invocation is still
|
||||
// stopped by the Run barrier before endpoint resolution.
|
||||
r.globalFlags = &GlobalFlags{DryRun: true}
|
||||
if got, err := r.Run(context.Background(), directInv); err != nil || got.Response["dry_run"] != true || got.Response["fallback"] != nil {
|
||||
t.Fatalf("direct runtime dry miss = %#v %v", got, err)
|
||||
}
|
||||
|
||||
for _, result := range []executor.Result{{}, {Response: map[string]any{"content": "value"}}, {Response: map[string]any{"value": 1}}} {
|
||||
@@ -961,7 +781,7 @@ func TestCrossPlatformCoverageExecuteInvocationCoverage(t *testing.T) {
|
||||
t.Fatalf("stdio dry-run = %#v %v", got, err)
|
||||
}
|
||||
stdioInv.DryRun = false
|
||||
if _, err := r.executeStdioInvocation(context.Background(), stdioInv); err == nil {
|
||||
if _, err := r.executeStdioInvocationAtEndpoint(context.Background(), "", stdioInv); err == nil {
|
||||
t.Fatal("missing stdio client succeeded")
|
||||
}
|
||||
}
|
||||
@@ -1657,12 +1477,13 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
CorpID: "corp", UserID: "user", ClientID: "client",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
var cancelCount int
|
||||
var subscribeCount, cancelCount int
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/event/sublist":
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"items": []map[string]any{{"subId": "sub", "eventKey": personal.EventMention, "ruleType": "at", "status": "active", "sourceId": "open"}}, "total": 1})
|
||||
case "/subscription/user":
|
||||
subscribeCount++
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "result": []string{"created"}})
|
||||
case "/subscription/cancel":
|
||||
cancelCount++
|
||||
@@ -1697,8 +1518,8 @@ func TestCrossPlatformCoveragePersonalEventCommandRuntimeCoverage(t *testing.T)
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{Common: commonConsumeOptions{Foreground: true}, EventKey: personal.EventMention, ControlBaseURL: server.URL, StreamTicketMode: "invalid"}); err == nil {
|
||||
t.Fatal("invalid foreground consume succeeded")
|
||||
}
|
||||
if cancelCount == 0 {
|
||||
t.Fatal("failed foreground consume did not clean up subscription")
|
||||
if subscribeCount != 0 || cancelCount != 0 {
|
||||
t.Fatalf("invalid local configuration reached subscription control: subscribe=%d cancel=%d", subscribeCount, cancelCount)
|
||||
}
|
||||
|
||||
if err := runPersonalEventStop(cmd, personalStopOptions{SubscribeID: "sub", All: true, ControlBaseURL: server.URL}); err == nil {
|
||||
@@ -1763,9 +1584,6 @@ func TestCrossPlatformCoverageDoctorCommandCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, jsonOut := range []bool{false, true} {
|
||||
if got := doctorCheckCache(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatal("cache check failed")
|
||||
}
|
||||
if got := doctorCheckPerf(io.Discard, jsonOut); got.Status != statusPass {
|
||||
t.Fatalf("perf check = %#v", got)
|
||||
}
|
||||
@@ -2184,7 +2002,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
_ = os.Symlink(filepath.Join(mono, "SKILL.md"), filepath.Join(mono, "linked.md"))
|
||||
multi := filepath.Join(t.TempDir(), "multi")
|
||||
for _, name := range []string{"dws-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
for _, name := range []string{"dingtalk-shared", "dingtalk-a", "dingtalk-b"} {
|
||||
dir := filepath.Join(multi, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2209,10 +2027,10 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, warnings, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") || warnings == "" {
|
||||
t.Fatalf("multi setup = %q / %q, %v", output, warnings, err)
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--skill", "a"); err != nil || !strings.Contains(output, "installed=2") {
|
||||
t.Fatalf("multi setup = %q, %v", output, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dws-shared", "SKILL.md")); err != nil {
|
||||
if _, err := os.Stat(filepath.Join(home, ".agents", "skills", "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if output, _, err := run("--mode", "multi", "--source", multi, "--target", "agents", "--yes", "--dry-run", "--exclude", "b"); err != nil || !strings.Contains(output, "DRY-RUN") {
|
||||
@@ -2236,7 +2054,7 @@ func TestCrossPlatformCoverageSkillSetupRuntimeCoverage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dws-shared"}
|
||||
all := []string{"dingtalk-a", "dingtalk-b", "dingtalk-shared"}
|
||||
for _, tc := range []struct {
|
||||
include []string
|
||||
exclude []string
|
||||
@@ -2254,7 +2072,7 @@ func TestCrossPlatformCoverageSkillSetupPureCoverage(t *testing.T) {
|
||||
t.Errorf("filter %#v/%#v = %v", tc.include, tc.exclude, err)
|
||||
}
|
||||
}
|
||||
for _, selected := range [][]string{nil, {"dws-shared"}, {"dingtalk-a"}} {
|
||||
for _, selected := range [][]string{nil, {"dingtalk-shared"}, {"dingtalk-a"}} {
|
||||
_ = ensureMandatorySharedSkill(selected, all)
|
||||
}
|
||||
_ = ensureMandatorySharedSkill([]string{"dingtalk-a"}, []string{"dingtalk-a"})
|
||||
|
||||
@@ -298,11 +298,10 @@ func TestCrossPlatformCoverageRootUtilityAndTimingCoverage(t *testing.T) {
|
||||
deduplicateCommands(dedupRoot)
|
||||
addPluginCommandsSafe(dedupRoot, []*cobra.Command{{Use: "same"}, {Use: "new"}})
|
||||
_ = newCompletionCommand(dedupRoot)
|
||||
_ = newCatalogCommand(nil)
|
||||
_ = newCatalogCommand()
|
||||
_ = newConfigCommand()
|
||||
_ = newCacheCommand()
|
||||
_ = newVersionCommand()
|
||||
_ = newRecoveryCommand(context.Background(), nil, &GlobalFlags{})
|
||||
_ = newAPICommand(&GlobalFlags{})
|
||||
_ = NewRootCommand(context.Background())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/testseam"
|
||||
)
|
||||
|
||||
func TestCrossPlatformCoverageTokenResolutionErrorBranches(t *testing.T) {
|
||||
if err := tokenResolutionError(nil); err != nil {
|
||||
t.Fatalf("nil error must pass through: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.Canceled); !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("context cancellation must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(context.DeadlineExceeded); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline exceeded must pass through unwrapped: %v", err)
|
||||
}
|
||||
if err := tokenResolutionError(authpkg.ErrTokenDataNotFound); err == nil ||
|
||||
!strings.Contains(err.Error(), "dws auth login") {
|
||||
t.Fatalf("not-found must become a login guidance error: %v", err)
|
||||
}
|
||||
generic := errors.New("keychain locked")
|
||||
if err := tokenResolutionError(generic); err == nil ||
|
||||
!strings.Contains(err.Error(), "resolve access token") || !errors.Is(err, generic) {
|
||||
t.Fatalf("generic error must wrap with resolve access token: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageProfileSwitchCellsSameCorp(t *testing.T) {
|
||||
profile := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserName: "Alice", UserID: "u1"}
|
||||
cfg := &authpkg.ProfilesConfig{Profiles: []authpkg.Profile{
|
||||
profile,
|
||||
{CorpID: "corp-1", CorpName: "钉钉", UserName: "Bob", UserID: "u2"},
|
||||
}}
|
||||
org, _ := profileSwitchProfileCells(profile, cfg)
|
||||
if !strings.Contains(org, " / Alice") {
|
||||
t.Fatalf("same-corp label must disambiguate with user name: %q", org)
|
||||
}
|
||||
|
||||
noName := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉", UserID: "u1"}
|
||||
org, _ = profileSwitchProfileCells(noName, cfg)
|
||||
if !strings.Contains(org, " / u1") {
|
||||
t.Fatalf("blank user name must fall back to user id: %q", org)
|
||||
}
|
||||
|
||||
blankUser := authpkg.Profile{CorpID: "corp-1", CorpName: "钉钉"}
|
||||
org, _ = profileSwitchProfileCells(blankUser, cfg)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("blank user name and id must not append a suffix: %q", org)
|
||||
}
|
||||
|
||||
org, _ = profileSwitchProfileCells(profile, nil)
|
||||
if strings.Contains(org, " / ") {
|
||||
t.Fatalf("nil config must not append a suffix: %q", org)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAuthRefreshRetryHelpers(t *testing.T) {
|
||||
var nilErr *authRefreshFailureError
|
||||
if got := nilErr.Unwrap(); got != nil {
|
||||
t.Fatalf("nil receiver Unwrap = %v, want nil", got)
|
||||
}
|
||||
rejection := errors.New("rejected")
|
||||
refresh := errors.New("refresh boom")
|
||||
wrapped := (&authRefreshFailureError{rejection: rejection, refresh: refresh}).Unwrap()
|
||||
if len(wrapped) != 2 || !errors.Is(wrapped[0], rejection) || !errors.Is(wrapped[1], refresh) {
|
||||
t.Fatalf("Unwrap must surface both causes: %v", wrapped)
|
||||
}
|
||||
|
||||
if ctx := withAuthRetrying(nil); ctx == nil || !IsAuthRetrying(ctx) {
|
||||
t.Fatal("withAuthRetrying(nil) must produce a retry-marked background context")
|
||||
}
|
||||
|
||||
var nilRunner *runtimeRunner
|
||||
if nilRunner.managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil runner must not manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{}).managesRuntimeOAuth(true) {
|
||||
t.Fatal("plugin-owned auth must opt out of runtime OAuth management")
|
||||
}
|
||||
if !(&runtimeRunner{}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("nil global flags must manage runtime OAuth")
|
||||
}
|
||||
if !(&runtimeRunner{globalFlags: &GlobalFlags{}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("blank token must manage runtime OAuth")
|
||||
}
|
||||
if (&runtimeRunner{globalFlags: &GlobalFlags{Token: "tok"}}).managesRuntimeOAuth(false) {
|
||||
t.Fatal("explicit token must not manage runtime OAuth")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageForceRefreshRejectedGuards(t *testing.T) {
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
testseam.Swap(t, &loadRefreshTokenData, func(string) (*authpkg.TokenData, error) { return nil, nil })
|
||||
if _, err := ForceRefreshAccessToken(context.Background(), t.TempDir()); err == nil ||
|
||||
!strings.Contains(err.Error(), "stored access token is empty") {
|
||||
t.Fatalf("nil token data must report stored access token is empty: %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), " ", "tok"); err == nil ||
|
||||
!strings.Contains(err.Error(), "config directory is empty") {
|
||||
t.Fatalf("blank config dir error = %v", err)
|
||||
}
|
||||
if _, err := forceRefreshRejectedAccessToken(context.Background(), t.TempDir(), " "); err == nil ||
|
||||
!strings.Contains(err.Error(), "rejected access token is empty") {
|
||||
t.Fatalf("blank rejected token error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageGetCachedRuntimeTokenSeam(t *testing.T) {
|
||||
token, err := getCachedRuntimeToken(context.Background())
|
||||
if err != nil && strings.TrimSpace(token) != "" {
|
||||
t.Fatalf("failed token resolution must not return a token: %q / %v", token, err)
|
||||
}
|
||||
}
|
||||
+35
-120
@@ -16,12 +16,10 @@ package app
|
||||
import (
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
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/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/mcptypes"
|
||||
@@ -112,54 +110,9 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
products := make(map[string]bool)
|
||||
aliases := make(map[string]string)
|
||||
toolEndpoints := make(map[string]string)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints)
|
||||
registerDynamicServer(defaultPATServerDescriptor(), endpoints, products, aliases, toolEndpoints, false)
|
||||
for _, server := range servers {
|
||||
if server.CLI.Skip {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
endpoints[id] = endpoint
|
||||
products[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias → CLI.ID mapping
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, endpoints, products, aliases, toolEndpoints, false)
|
||||
}
|
||||
dynamicEndpoints = endpoints
|
||||
dynamicProducts = products
|
||||
@@ -167,7 +120,13 @@ func SetDynamicServers(servers []mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = toolEndpoints
|
||||
}
|
||||
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string) {
|
||||
// registerDynamicServer is the shared dynamic-server registration core used by
|
||||
// SetDynamicServers and AppendDynamicServer. It records the descriptor's
|
||||
// endpoint under its ID, command name, and aliases, and builds the tool →
|
||||
// endpoint map from its CLI tools and overrides. With protectCommandKey=true
|
||||
// (AppendDynamicServer) an already-registered command-key endpoint is never
|
||||
// overwritten; with false the entry is replaced.
|
||||
func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[string]string, products map[string]bool, aliases map[string]string, toolEndpoints map[string]string, protectCommandKey bool) {
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
@@ -179,7 +138,13 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
endpoints[cmd] = endpoint
|
||||
if protectCommandKey {
|
||||
if _, exists := endpoints[cmd]; !exists {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
} else {
|
||||
endpoints[cmd] = endpoint
|
||||
}
|
||||
products[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
@@ -187,11 +152,11 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
if alias != "" && endpoint != "" {
|
||||
endpoints[alias] = endpoint
|
||||
products[alias] = true
|
||||
// Build alias -> CLI.ID mapping.
|
||||
// Build alias → CLI.ID mapping.
|
||||
aliases[alias] = id
|
||||
}
|
||||
}
|
||||
// Build tool -> endpoint mapping from CLI tools and overrides.
|
||||
// Build tool → endpoint mapping from CLI tools and overrides.
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
@@ -199,19 +164,29 @@ func registerDynamicServer(server mcptypes.ServerDescriptor, endpoints map[strin
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName := range server.CLI.ToolOverrides {
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName != "" {
|
||||
toolEndpoints[toolName] = endpoint
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint (e.g. chat's "search_my_robots" → bot). Registering
|
||||
// them here would overwrite the real owner's tool → endpoint
|
||||
// mapping and send the invocation to the wrong MCP URL.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
toolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// shouldUseDirectRuntime gates endpoint resolution to the only invocation
|
||||
// kinds the executor constructs: compat_invocation via
|
||||
// NewCompatibilityInvocation and helper_invocation via NewHelperInvocation.
|
||||
// Every other kind skips direct-runtime resolution and terminates at
|
||||
// handleCatalogMiss in the runner.
|
||||
func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
if strings.TrimSpace(os.Getenv(cli.CatalogFixtureEnv)) != "" {
|
||||
return false
|
||||
}
|
||||
switch invocation.Kind {
|
||||
case "compat_invocation", "helper_invocation":
|
||||
return true
|
||||
@@ -220,22 +195,6 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool {
|
||||
}
|
||||
}
|
||||
|
||||
// directRuntimeToolEndpoint returns the MCP endpoint owned by the server
|
||||
// whose toolOverrides registered this tool name. Used to correct catalog
|
||||
// lookups when two envelope servers share the same cli.command and the
|
||||
// per-product endpoint map collides (see runner.go cross-check).
|
||||
func directRuntimeToolEndpoint(toolName string) (string, bool) {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
return "", false
|
||||
}
|
||||
dynamicMu.RLock()
|
||||
defer dynamicMu.RUnlock()
|
||||
|
||||
endpoint, ok := dynamicToolEndpoints[toolName]
|
||||
return endpoint, ok && strings.TrimSpace(endpoint) != ""
|
||||
}
|
||||
|
||||
func directRuntimeEndpoint(productID, toolName string) (string, bool) {
|
||||
// Priority 0: env-var override always wins (DINGTALK_<PRODUCT>_MCP_URL).
|
||||
normalized := normalizeDirectRuntimeProductID(productID)
|
||||
@@ -377,51 +336,7 @@ func AppendDynamicServer(server mcptypes.ServerDescriptor) {
|
||||
dynamicToolEndpoints = make(map[string]string)
|
||||
}
|
||||
|
||||
if server.CLI.Skip {
|
||||
return
|
||||
}
|
||||
|
||||
id := strings.TrimSpace(server.CLI.ID)
|
||||
endpoint := strings.TrimSpace(server.Endpoint)
|
||||
if id != "" && endpoint != "" {
|
||||
dynamicEndpoints[id] = endpoint
|
||||
dynamicProducts[id] = true
|
||||
}
|
||||
cmd := strings.TrimSpace(server.CLI.Command)
|
||||
if cmd != "" && cmd != id && endpoint != "" {
|
||||
if _, exists := dynamicEndpoints[cmd]; !exists {
|
||||
dynamicEndpoints[cmd] = endpoint
|
||||
}
|
||||
dynamicProducts[cmd] = true
|
||||
}
|
||||
for _, alias := range server.CLI.Aliases {
|
||||
alias = strings.TrimSpace(alias)
|
||||
if alias != "" && endpoint != "" {
|
||||
dynamicEndpoints[alias] = endpoint
|
||||
dynamicProducts[alias] = true
|
||||
dynamicAliases[alias] = id
|
||||
}
|
||||
}
|
||||
if endpoint != "" {
|
||||
for _, tool := range server.CLI.Tools {
|
||||
toolName := strings.TrimSpace(tool.Name)
|
||||
if toolName != "" {
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
for toolName, override := range server.CLI.ToolOverrides {
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if toolName == "" {
|
||||
continue
|
||||
}
|
||||
// Leaves with serverOverride are routed to a different server's
|
||||
// endpoint; skip to avoid overwriting the real owner's mapping.
|
||||
if strings.TrimSpace(override.ServerOverride) != "" {
|
||||
continue
|
||||
}
|
||||
dynamicToolEndpoints[toolName] = endpoint
|
||||
}
|
||||
}
|
||||
registerDynamicServer(server, dynamicEndpoints, dynamicProducts, dynamicAliases, dynamicToolEndpoints, true)
|
||||
}
|
||||
|
||||
func normalizeDirectRuntimeProductID(productID string) string {
|
||||
|
||||
@@ -61,7 +61,7 @@ func newDoctorCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "doctor",
|
||||
Short: "环境健康检查",
|
||||
Long: "一键检查登录态、网络连通性、缓存状态和版本更新,快速定位常见问题。",
|
||||
Long: "一键检查登录态、网络连通性和版本更新,快速定位常见问题。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runDoctor,
|
||||
@@ -92,9 +92,6 @@ func runDoctor(cmd *cobra.Command, _ []string) error {
|
||||
networkResult := doctorCheckNetwork(cmd.Context(), w, jsonOut, networkTimeout)
|
||||
checks = append(checks, networkResult)
|
||||
|
||||
cacheResult := doctorCheckCache(w, jsonOut)
|
||||
checks = append(checks, cacheResult)
|
||||
|
||||
versionResult := doctorCheckVersion(w, jsonOut, networkTimeout)
|
||||
checks = append(checks, versionResult)
|
||||
|
||||
@@ -297,24 +294,6 @@ func doctorCheckNetwork(ctx context.Context, w io.Writer, jsonOut bool, timeout
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Cache check ─────────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckCache(w io.Writer, jsonOut bool) checkResult {
|
||||
if !jsonOut {
|
||||
fmt.Fprint(w, tui.Dim("检查缓存状态... "))
|
||||
}
|
||||
|
||||
r := checkResult{
|
||||
Name: "cache",
|
||||
Status: statusPass,
|
||||
Message: "静态端点模式, 无需缓存",
|
||||
}
|
||||
if !jsonOut {
|
||||
printCheckResult(w, r)
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ── Version check ───────────────────────────────────────────────────────
|
||||
|
||||
func doctorCheckVersion(w io.Writer, jsonOut bool, timeout time.Duration) checkResult {
|
||||
|
||||
@@ -109,31 +109,6 @@ func TestPrintCheckResultNoHint(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmpty(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, false)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckCacheEmptyJSON(t *testing.T) {
|
||||
t.Setenv("DWS_CACHE_DIR", t.TempDir())
|
||||
|
||||
var buf bytes.Buffer
|
||||
r := doctorCheckCache(&buf, true)
|
||||
|
||||
if r.Status != statusPass {
|
||||
t.Errorf("expected pass for static endpoint mode, got %s", r.Status)
|
||||
}
|
||||
if buf.Len() != 0 {
|
||||
t.Error("expected no output in JSON mode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctorCheckAuthReportsKeychainUnavailable(t *testing.T) {
|
||||
t.Setenv("DWS_CONFIG_DIR", filepath.Join(t.TempDir(), "config"))
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
@@ -36,6 +37,66 @@ func TestToolCallerAdapterDryRunNeverInvokesRunner(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolCallerAdapterDryRunAllowsOnlyExplicitReadCapability(t *testing.T) {
|
||||
runner := &readOnlyDryRunRunner{}
|
||||
caller := newToolCallerAdapter(runner, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
|
||||
result, err := caller.(edition.ReadToolCaller).CallReadTool(
|
||||
context.Background(),
|
||||
"im",
|
||||
"search_groups",
|
||||
map[string]any{"keyword": "project"},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("CallReadTool() error = %v", err)
|
||||
}
|
||||
if got := runner.readCalls.Load(); got != 1 {
|
||||
t.Fatalf("read calls = %d, want 1", got)
|
||||
}
|
||||
if got := runner.regularCalls.Load(); got != 0 {
|
||||
t.Fatalf("regular calls = %d, want 0", got)
|
||||
}
|
||||
if runner.invocation.DryRun {
|
||||
t.Fatal("read-only invocation was left in dry-run mode")
|
||||
}
|
||||
if result == nil || len(result.Content) != 1 || !strings.Contains(result.Content[0].Text, `"read":true`) {
|
||||
t.Fatalf("read result = %#v", result)
|
||||
}
|
||||
|
||||
failClosed := newToolCallerAdapter(&countingErrorRunner{}, &GlobalFlags{DryRun: true})
|
||||
if _, err := failClosed.(edition.ReadToolCaller).CallReadTool(
|
||||
context.Background(), "im", "search_groups", nil,
|
||||
); err == nil {
|
||||
t.Fatal("runner without read-only capability was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReadOnlyGuardErrorPaths(t *testing.T) {
|
||||
var nilAdapter *toolCallerAdapter
|
||||
if _, err := nilAdapter.CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
|
||||
t.Fatal("nil adapter accepted a read-only call")
|
||||
}
|
||||
|
||||
regularRunner := &capturingSuccessRunner{}
|
||||
regular := newToolCallerAdapter(regularRunner, &GlobalFlags{DryRun: false, Format: "json"})
|
||||
if _, err := regular.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err != nil {
|
||||
t.Fatalf("non-dry read should use the regular runner: %v", err)
|
||||
}
|
||||
if got := regularRunner.calls.Load(); got != 1 {
|
||||
t.Fatalf("regular runner calls = %d, want 1", got)
|
||||
}
|
||||
|
||||
readFailure := newToolCallerAdapter(&failingReadOnlyRunner{}, &GlobalFlags{DryRun: true, Format: "json"})
|
||||
if _, err := readFailure.(edition.ReadToolCaller).CallReadTool(context.Background(), "im", "search_groups", nil); err == nil {
|
||||
t.Fatal("read-only runner error was swallowed")
|
||||
}
|
||||
|
||||
var nilRuntime *runtimeRunner
|
||||
if _, err := nilRuntime.RunReadOnly(context.Background(), executor.Invocation{}); err == nil {
|
||||
t.Fatal("nil runtime runner accepted a read-only call")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
fallback := &countingErrorRunner{}
|
||||
runner := &runtimeRunner{globalFlags: &GlobalFlags{DryRun: true}, fallback: fallback}
|
||||
@@ -56,6 +117,38 @@ func TestRuntimeRunnerGlobalDryRunStopsBeforeInjectedFallback(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerReadOnlyClonePreservesGlobalDryRunBarrier(t *testing.T) {
|
||||
fallback := &capturingSuccessRunner{}
|
||||
flags := &GlobalFlags{DryRun: true}
|
||||
runner := &runtimeRunner{globalFlags: flags, fallback: fallback}
|
||||
invocation := executor.NewHelperInvocation(
|
||||
"test",
|
||||
"im",
|
||||
"search_groups",
|
||||
map[string]any{"keyword": "project"},
|
||||
)
|
||||
|
||||
if _, err := runner.RunReadOnly(context.Background(), invocation); err != nil {
|
||||
t.Fatalf("RunReadOnly() error = %v", err)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 1 {
|
||||
t.Fatalf("fallback calls = %d, want 1", got)
|
||||
}
|
||||
if fallback.invocation.DryRun {
|
||||
t.Fatal("read-only fallback invocation was left in dry-run mode")
|
||||
}
|
||||
if !flags.DryRun {
|
||||
t.Fatal("RunReadOnly mutated the process-wide dry-run flag")
|
||||
}
|
||||
|
||||
if _, err := runner.Run(context.Background(), invocation); err != nil {
|
||||
t.Fatalf("ordinary Run() error = %v", err)
|
||||
}
|
||||
if got := fallback.calls.Load(); got != 1 {
|
||||
t.Fatalf("ordinary dry-run reached fallback; calls = %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
type countingErrorRunner struct {
|
||||
calls atomic.Int64
|
||||
}
|
||||
@@ -64,3 +157,47 @@ func (r *countingErrorRunner) Run(context.Context, executor.Invocation) (executo
|
||||
r.calls.Add(1)
|
||||
return executor.Result{}, errors.New("runner must not be called")
|
||||
}
|
||||
|
||||
type readOnlyDryRunRunner struct {
|
||||
regularCalls atomic.Int64
|
||||
readCalls atomic.Int64
|
||||
invocation executor.Invocation
|
||||
}
|
||||
|
||||
func (r *readOnlyDryRunRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
r.regularCalls.Add(1)
|
||||
return executor.Result{}, errors.New("regular runner must not be called")
|
||||
}
|
||||
|
||||
func (r *readOnlyDryRunRunner) RunReadOnly(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.readCalls.Add(1)
|
||||
r.invocation = invocation
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"read": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type capturingSuccessRunner struct {
|
||||
calls atomic.Int64
|
||||
invocation executor.Invocation
|
||||
}
|
||||
|
||||
func (r *capturingSuccessRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.calls.Add(1)
|
||||
r.invocation = invocation
|
||||
return executor.Result{
|
||||
Invocation: invocation,
|
||||
Response: map[string]any{"read": true},
|
||||
}, nil
|
||||
}
|
||||
|
||||
type failingReadOnlyRunner struct{}
|
||||
|
||||
func (*failingReadOnlyRunner) Run(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("regular runner must not be called")
|
||||
}
|
||||
|
||||
func (*failingReadOnlyRunner) RunReadOnly(context.Context, executor.Invocation) (executor.Result, error) {
|
||||
return executor.Result{}, errors.New("read failed")
|
||||
}
|
||||
|
||||
+210
-48
@@ -29,9 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
@@ -73,6 +75,20 @@ var (
|
||||
// newEventCommand returns the `event` parent command and all its subcommands.
|
||||
// Wired into root.go's utilityCommands list.
|
||||
func newEventCommand() *cobra.Command {
|
||||
// Product-level Agent routing Decl (migrated from selection/event.json
|
||||
// products.event). Catalog assembly stamps provenance contract_final.
|
||||
contract.RegisterProductDecl(contract.ProductDecl{
|
||||
ID: "event",
|
||||
Selection: contract.ProductSelectionDecl{
|
||||
AgentSummary: "订阅/消费个人消息、动作与群生命周期事件,并管理订阅生命周期",
|
||||
UseWhen: []string{
|
||||
"需要实时监听个人消息接收、全量消息、已读、撤回、表情回应或群生命周期事件,或管理个人事件订阅生命周期",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"查历史聊天或主动发消息分别用 chat 查询/发送命令",
|
||||
},
|
||||
},
|
||||
})
|
||||
cmd := &cobra.Command{
|
||||
Use: "event",
|
||||
Short: "事件订阅 (DingTalk Stream 长连接)",
|
||||
@@ -83,6 +99,7 @@ func newEventCommand() *cobra.Command {
|
||||
RunE: func(c *cobra.Command, _ []string) error { return c.Help() },
|
||||
}
|
||||
cmd.AddCommand(
|
||||
newEventListenIMCommand(),
|
||||
newEventConsumeCommand(),
|
||||
newEventListCommand(),
|
||||
newEventSchemaCommand(),
|
||||
@@ -118,7 +135,7 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "consume [event_key]",
|
||||
Use: "consume [event_key...]",
|
||||
Short: "订阅事件流并输出到 stdout",
|
||||
Long: `订阅 DingTalk 个人事件并将每条事件以 NDJSON 输出到 stdout。
|
||||
|
||||
@@ -138,9 +155,14 @@ func newEventConsumeCommand() *cobra.Command {
|
||||
--profile。连上后 stderr 打就绪行 [event] ready,等它出现再读 stdout;停机用
|
||||
SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览、确认后加
|
||||
--yes,绝不要 kill -9。
|
||||
|
||||
可提供多个 event_key。多事件会各自创建订阅和本地 consumer,但共享同一 bus、
|
||||
远程连接、输出和 duration/max-events。用户类事件必须共享一个 --user 或
|
||||
--open-dingtalk-id,群类事件必须共享一个 --group;用户类与群类不能混用。
|
||||
全部 consumer 就绪后 stderr 输出 [event] ready event_count=<n> bus_pid=<pid>。
|
||||
--event-types/--filter 只影响本地 bus → consume 这一段投递;普通个人事件消费
|
||||
通常不需要设置。`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
Args: cobra.ArbitraryArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
@@ -148,8 +170,17 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
personalOpts.EventKey = firstArg(args)
|
||||
personalOpts.EventKeys = dedupePersonalEventKeys(args)
|
||||
personalOpts.EventKey = firstArg(personalOpts.EventKeys)
|
||||
personalOpts.Flatten = flatten
|
||||
if len(personalOpts.EventKeys) > 1 {
|
||||
if err := rejectPersonalMultiEventFlags(c,
|
||||
"subscribe-id", "rule", "event-types", "filter",
|
||||
"foreground", "force", "debug-raw-events",
|
||||
); err != nil {
|
||||
return fmt.Errorf("event consume: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
personalOpts.Common = commonConsumeOptions{
|
||||
EventTypes: eventTypes,
|
||||
Filter: filter,
|
||||
@@ -329,7 +360,7 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&personalOpts.GroupID, "group", "",
|
||||
"group 规则:openConversationId")
|
||||
f.StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "",
|
||||
"个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
"个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
f.BoolVar(&personalOpts.DebugRawEvents, "debug-raw-events", false,
|
||||
"个人事件联调:绕过本地 event type/subscribe_id 过滤,输出当前 personal stream bus 收到的所有事件")
|
||||
f.StringVar(&streamOpts.Mode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")),
|
||||
@@ -337,15 +368,56 @@ SIGTERM、关 stdin,或先用 dws event stop <subscribe_id> --dry-run 预览
|
||||
f.StringVar(&streamOpts.SourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人 Stream sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
f.StringVar(&streamOpts.TicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")),
|
||||
"个人 Stream 取票 URL;默认由 MCP base URL 派生")
|
||||
"个人 Stream 取票 URL;默认由 MCP base 派生 /stream/connections/ticket")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要消费的个人事件码;省略时仅适用于显式配置其它事件来源的兼容模式",
|
||||
Description: "要消费的一个或多个个人事件码;多个事件必须共享同一目标和过滤上下文",
|
||||
Required: false,
|
||||
Variadic: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "consume",
|
||||
CanonicalPath: "event.consume",
|
||||
CLIPath: "event consume",
|
||||
PrimaryCLIPath: "event consume",
|
||||
},
|
||||
Description: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command creates or reuses a remote personal-event subscription and coordinates the local event bus and Stream consumer; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "订阅并持续消费一个或多个兼容的个人事件;Agent 使用 --flatten 输出顶层业务 NDJSON",
|
||||
UseWhen: []string{
|
||||
"需要实时监听 @我、指定单聊、指定群或指定发送人的后续消息事件",
|
||||
"用户明确要求监听当前身份的所有单聊或所有群消息",
|
||||
"需要监听指定单聊或群聊中的消息已读、撤回或表情回应事件",
|
||||
"需要监听指定群的标题变更、成员进退群或群解散事件",
|
||||
"监听机器人、外部联系人等以 openDingtalkId 标识的单聊目标",
|
||||
"同一目标、同一过滤条件需要同时监听多个兼容事件",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"只查历史聊天记录时用 chat 查询命令",
|
||||
"只看事件目录/字段时用 event list / event schema",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event consume user_im_message_receive_user --open-dingtalk-id open-example --flatten --max-events 1 --format ndjson",
|
||||
"dws event consume user_im_message_receive_o2o user_im_message_read_o2o --user test-user-001 --flatten --max-events 2 --format ndjson",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -713,6 +785,36 @@ func newEventListCommand() *cobra.Command {
|
||||
cmd.Flags().BoolVar(&enabledOnly, "enabled-only", false, "个人事件目录只显示 enabled")
|
||||
cmd.Flags().BoolVar(&includePending, "include-pending", false, "个人事件目录包含 pending 项")
|
||||
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "list",
|
||||
CanonicalPath: "event.list",
|
||||
CLIPath: "event list",
|
||||
PrimaryCLIPath: "event list",
|
||||
},
|
||||
Description: "列出支持的个人事件目录与状态说明",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件目录,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "列出支持的个人事件目录与状态说明",
|
||||
UseWhen: []string{"尚不知道可用 event_key,需要先盘点个人事件目录"},
|
||||
AvoidWhen: []string{
|
||||
"已知 event_key 要看 payload 字段时用 event schema",
|
||||
"要开始监听时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event list --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -777,10 +879,37 @@ func newEventStatusCommand() *cobra.Command {
|
||||
cmd.Flags().StringVar(&personalOpts.EventKey, "event", "", "个人事件 event_key 过滤")
|
||||
cmd.Flags().StringVar(&personalOpts.Status, "status", "active", "个人订阅状态过滤: active|paused|error|deleted|all")
|
||||
cmd.Flags().StringVar(&personalOpts.SubscribeID, "subscribe-id", "", "个人订阅 ID 过滤")
|
||||
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
cmd.Flags().StringVar(&personalOpts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
cmd.Flags().StringVar(&personalOpts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
hideEventInternalFlags(cmd, "as", "all", "all-editions", "client-id", "fail-on-orphan")
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "status",
|
||||
CanonicalPath: "event.status",
|
||||
CLIPath: "event status",
|
||||
PrimaryCLIPath: "event status",
|
||||
},
|
||||
Description: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command reads the remote personal-event subscription control plane and combines it with local bus and consumer state; no single pinned RPC represents the result.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查看个人事件订阅、本地 bus 与消费进程状态",
|
||||
UseWhen: []string{"需要确认订阅是否活跃、bus/consume 是否仍在运行"},
|
||||
AvoidWhen: []string{"取消订阅用 event stop;列事件目录用 event list"},
|
||||
Examples: []string{"dws event status --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1022,39 +1151,22 @@ func newEventStopCommand() *cobra.Command {
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
// Local validation runs in DeclareLeafMetadata Validate (PreRunE)
|
||||
// before ConfirmSafety. Confirmation is the framework wrap.
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
opts.SubscribeID = firstArg(args)
|
||||
hasSubscribeID := strings.TrimSpace(opts.SubscribeID) != ""
|
||||
if hasSubscribeID && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if !hasSubscribeID && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
if !eventStopConfirmed(c) {
|
||||
return eventStopConfirmationRequired("event stop 会取消个人事件订阅并停止本地消费")
|
||||
}
|
||||
return eventRunPersonalStop(c, opts)
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
|
||||
}
|
||||
if eventStopDryRun(c) {
|
||||
return writeEventStopDryRun(c, as, opts)
|
||||
}
|
||||
if !eventStopConfirmed(c) {
|
||||
return eventStopConfirmationRequired("event stop 会停止事件消费")
|
||||
}
|
||||
configDir := defaultConfigDir()
|
||||
clientID, _, _, _, err := eventResolveAppCredentials(configDir)
|
||||
if err != nil {
|
||||
@@ -1075,18 +1187,73 @@ func newEventStopCommand() *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&asIdentity, "as", "user", "事件身份: user")
|
||||
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base URL 派生为 /dws")
|
||||
cmd.Flags().StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
cmd.Flags().StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")),
|
||||
"个人事件 sourceId;开源版默认 open,可由 edition 覆盖")
|
||||
cmd.Flags().BoolVar(&opts.All, "all", false, "取消当前身份下本地记录的所有个人订阅")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "subscribe_id",
|
||||
Type: "string",
|
||||
Description: "要取消的个人事件订阅 ID;与 --all 二选一",
|
||||
Required: false,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "destructive", Risk: "high",
|
||||
Confirmation: "user_required", Idempotency: "unknown",
|
||||
},
|
||||
Validate: func(c *cobra.Command, args []string) error {
|
||||
as, err := eventNormalizeAs(asIdentity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if as == "user" {
|
||||
subscribeID := firstArg(args)
|
||||
hasSubscribeID := strings.TrimSpace(subscribeID) != ""
|
||||
if hasSubscribeID && opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id and --all are mutually exclusive")
|
||||
}
|
||||
if !hasSubscribeID && !opts.All {
|
||||
return fmt.Errorf("event stop --as user: subscribe_id is required unless --all is set")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if err := rejectChangedFlags(c, "user", "all", "personal-event-base-url", "stream-source-id"); err != nil {
|
||||
return fmt.Errorf("event stop: %w", err)
|
||||
}
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("event stop: subscribe_id is only supported with --as user")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "stop",
|
||||
CanonicalPath: "event.stop",
|
||||
CLIPath: "event stop",
|
||||
PrimaryCLIPath: "event stop",
|
||||
},
|
||||
Description: "取消个人事件订阅并停止对应本地消费",
|
||||
DryRun: &contract.DryRunSpec{PreviewKind: "request", RemoteReads: false},
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed composite workflow: the command deletes remote personal-event subscriptions, interrupts local consumers, updates local state, and may stop the local bus; no single pinned RPC represents the workflow.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "取消个人事件订阅并停止对应本地消费",
|
||||
UseWhen: []string{"用户明确要求取消已知 subscribe_id(或清理全部)并停止消费"},
|
||||
AvoidWhen: []string{
|
||||
"只需检查状态时用 event status",
|
||||
"目标订阅不明确或用户未确认时不要停止",
|
||||
},
|
||||
Examples: []string{"dws event stop SUBSCRIBE_ID --dry-run"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1095,20 +1262,6 @@ func eventStopDryRun(cmd *cobra.Command) bool {
|
||||
return value
|
||||
}
|
||||
|
||||
func eventStopConfirmed(cmd *cobra.Command) bool {
|
||||
value, _ := cmd.Flags().GetBool("yes")
|
||||
return value
|
||||
}
|
||||
|
||||
func eventStopConfirmationRequired(action string) error {
|
||||
return apperrors.NewValidation(
|
||||
action+";请先使用 --dry-run 预览,确认后加 --yes 执行",
|
||||
apperrors.WithReason("confirmation_required"),
|
||||
apperrors.WithHint("先以相同参数加 --dry-run 预览;获得用户确认后改用 --yes 执行"),
|
||||
apperrors.WithActions("使用 --dry-run 生成预览", "获得用户确认后使用 --yes 执行"),
|
||||
)
|
||||
}
|
||||
|
||||
func writeEventStopDryRun(cmd *cobra.Command, identity string, opts personalStopOptions) error {
|
||||
payload := map[string]any{
|
||||
"dry_run": true,
|
||||
@@ -1192,6 +1345,19 @@ func rejectPersonalEventUnsupportedFlags(c *cobra.Command, names ...string) erro
|
||||
return fmt.Errorf("%s are not supported for personal events", strings.Join(changed, ", "))
|
||||
}
|
||||
|
||||
func rejectPersonalMultiEventFlags(c *cobra.Command, names ...string) error {
|
||||
changed := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
if f := c.Flags().Lookup(name); f != nil && f.Changed {
|
||||
changed = append(changed, "--"+name)
|
||||
}
|
||||
}
|
||||
if len(changed) == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%s are not supported when consuming multiple events", strings.Join(changed, ", "))
|
||||
}
|
||||
|
||||
func rejectChangedFlags(c *cobra.Command, supportedAs string, names ...string) error {
|
||||
changed := make([]string, 0, len(names))
|
||||
for _, name := range names {
|
||||
@@ -1246,7 +1412,3 @@ func eventTypesWithDefault(types []string) []string {
|
||||
}
|
||||
return registry.CatchAllEventTypes()
|
||||
}
|
||||
|
||||
// compile-time guard: avoid "imported and not used" if any of these
|
||||
// indirect imports become unused after future refactors.
|
||||
var _ = io.Discard
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/corecmd/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMOptions struct {
|
||||
Kind string
|
||||
Events []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
UserQuery string
|
||||
ChatID string
|
||||
ChatQuery string
|
||||
QueryCSV string
|
||||
MaxEvents int
|
||||
Duration time.Duration
|
||||
DryRun bool
|
||||
ControlBaseURL string
|
||||
StreamTicketMode string
|
||||
StreamTicketURL string
|
||||
StreamSourceID string
|
||||
}
|
||||
|
||||
type listenIMPlan struct {
|
||||
EventKeys []string
|
||||
UserID string
|
||||
OpenDingTalkID string
|
||||
GroupID string
|
||||
ResolvedTargets []any
|
||||
}
|
||||
|
||||
type eventTargetReader struct{}
|
||||
|
||||
func (eventTargetReader) CallMCPData(product, tool string, params map[string]any) (map[string]any, error) {
|
||||
text, err := helpers.CallMCPReadToolTextOnServer(product, tool, params)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(text) == "" {
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(text), &data); err != nil {
|
||||
return nil, apperrors.NewInternal(fmt.Sprintf("解析 %s 返回失败: %v", tool, err))
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
var eventListenIMReader = func() targetresolver.Reader { return eventTargetReader{} }
|
||||
|
||||
func newEventListenIMCommand() *cobra.Command {
|
||||
var opts listenIMOptions
|
||||
cmd := &cobra.Command{
|
||||
Use: "+listen-im",
|
||||
Short: "按 IM 意图解析目标并监听一个或多个个人消息事件",
|
||||
Long: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey," +
|
||||
"自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
plan, err := compileListenIMPlan(eventListenIMReader(), opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event +listen-im: %w", err)
|
||||
}
|
||||
consumeOpts := personalConsumeOptions{
|
||||
EventKey: firstArg(plan.EventKeys),
|
||||
EventKeys: plan.EventKeys,
|
||||
Flatten: true,
|
||||
UserID: plan.UserID,
|
||||
OpenDingTalkID: plan.OpenDingTalkID,
|
||||
GroupID: plan.GroupID,
|
||||
QueryCSV: opts.QueryCSV,
|
||||
ControlBaseURL: opts.ControlBaseURL,
|
||||
StreamTicketMode: opts.StreamTicketMode,
|
||||
StreamTicketURL: opts.StreamTicketURL,
|
||||
StreamSourceID: opts.StreamSourceID,
|
||||
Common: commonConsumeOptions{
|
||||
FormatRaw: "ndjson",
|
||||
MaxEvents: opts.MaxEvents,
|
||||
Duration: opts.Duration,
|
||||
DryRun: opts.DryRun,
|
||||
},
|
||||
}
|
||||
return eventRunPersonalConsume(c, consumeOpts)
|
||||
},
|
||||
}
|
||||
f := cmd.Flags()
|
||||
f.StringVar(&opts.Kind, "kind", "at-me", "监听意图: at-me|sender|group|all-direct|all-group")
|
||||
f.StringSliceVar(&opts.Events, "events", []string{"message"}, "事件种类: message,reaction,read,recall")
|
||||
f.StringVar(&opts.UserID, "user", "", "指定发送人/单聊对端 userId")
|
||||
f.StringVar(&opts.OpenDingTalkID, "open-dingtalk-id", "", "指定发送人/单聊对端 openDingTalkId")
|
||||
f.StringVar(&opts.UserQuery, "user-query", "", "按姓名/花名唯一解析指定发送人")
|
||||
f.StringVar(&opts.ChatID, "chat-id", "", "指定群 openConversationId")
|
||||
f.StringVar(&opts.ChatQuery, "chat-query", "", "按群名唯一解析指定群")
|
||||
f.StringVar(&opts.QueryCSV, "query", "", "消息文本关键词过滤,逗号分隔;仅 message 事件")
|
||||
f.IntVar(&opts.MaxEvents, "max-events", 0, "收到 N 条后退出 (0 = 不限)")
|
||||
f.DurationVar(&opts.Duration, "duration", 0, "运行时长上限 (Go duration,如 30s/5m;0 = 不限)")
|
||||
f.BoolVar(&opts.DryRun, "dry-run", false, "解析目标并打印订阅计划,不创建订阅或连接 bus")
|
||||
f.StringVar(&opts.ControlBaseURL, "personal-event-base-url", "", "个人事件控制面 base URL;默认由 MCP base 派生 /dws")
|
||||
f.StringVar(&opts.StreamTicketMode, "stream-ticket-mode", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_MODE")), "个人 Stream 建联模式;默认 normal")
|
||||
f.StringVar(&opts.StreamSourceID, "stream-source-id", strings.TrimSpace(os.Getenv("DWS_STREAM_SOURCE_ID")), "个人 Stream sourceId;开源版默认 open")
|
||||
f.StringVar(&opts.StreamTicketURL, "stream-ticket-url", strings.TrimSpace(os.Getenv("DWS_STREAM_TICKET_URL")), "个人 Stream 取票 URL")
|
||||
hideEventInternalFlags(cmd, "personal-event-base-url", "stream-ticket-mode", "stream-source-id", "stream-ticket-url")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "kind", "at-me", "sender", "group", "all-direct", "all-group")
|
||||
cli.AnnotateRuntimeFlagEnum(cmd, "events", "message", "reaction", "read", "recall")
|
||||
cli.AnnotateRuntimeConstraints(cmd, cli.RuntimeSchemaConstraints{
|
||||
MutuallyExclusive: [][]string{{"user", "open-dingtalk-id", "user-query", "chat-id", "chat-query"}},
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "write", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "non_idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "listen_im",
|
||||
CanonicalPath: "event.listen_im",
|
||||
CLIPath: "event +listen-im",
|
||||
PrimaryCLIPath: "event +listen-im",
|
||||
},
|
||||
Description: "把 @我、指定发送人、指定群、全部单聊或全部群聊等用户意图确定性编译为个人 EventKey,自然姓名/群名会先唯一解析,再复用 event consume 的订阅、ready marker、NDJSON、取消、回滚和清理生命周期。",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed IM event facade: it deterministically maps kind/events to public personal EventKeys, resolves one natural user/chat target with the shared typed resolver, then delegates one single- or multi-event invocation to the existing subscription, bus, ready-marker, NDJSON, rollback, cancellation, and cleanup lifecycle.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 @我、姓名、群名或全量范围监听一个或多个 IM 消息事件",
|
||||
UseWhen: []string{
|
||||
"已知要监听 @我、指定发送人、指定群、全部单聊或全部群聊的 message/reaction/read/recall 事件时使用;姓名用 --user-query、群名用 --chat-query,CLI 会唯一解析目标并把多个兼容事件合并到一个消费生命周期。",
|
||||
},
|
||||
AvoidWhen: []string{
|
||||
"需要群标题/成员/解散等生命周期事件、显式 EventKey、复用 subscribe_id、Filter DSL、原始 transport envelope 或其它底层 consume 控制时使用 event consume;只查历史消息时使用 chat 查询入口",
|
||||
},
|
||||
Examples: []string{
|
||||
"dws event +listen-im --kind at-me --max-events 1",
|
||||
"dws event +listen-im --kind group --events message,reaction --chat-id <openConversationId> --duration 10m",
|
||||
},
|
||||
},
|
||||
Parameters: []contract.ParamDecl{
|
||||
{Name: "chat-id", Property: "chatId"},
|
||||
{Name: "chat-query", Property: "chatQuery"},
|
||||
{Name: "dry-run", Property: "dryRun"},
|
||||
{Name: "duration", Property: "duration"},
|
||||
{Name: "events", Property: "events"},
|
||||
{Name: "kind", Property: "kind"},
|
||||
{Name: "max-events", Property: "maxEvents"},
|
||||
{Name: "open-dingtalk-id", Property: "openDingtalkId"},
|
||||
{Name: "query", Property: "query"},
|
||||
{Name: "user", Property: "user"},
|
||||
{Name: "user-query", Property: "userQuery"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func compileListenIMPlan(reader targetresolver.Reader, opts listenIMOptions) (listenIMPlan, error) {
|
||||
kind := strings.ToLower(strings.TrimSpace(opts.Kind))
|
||||
if kind == "" {
|
||||
kind = "at-me"
|
||||
}
|
||||
events := uniqueListenIMValues(opts.Events)
|
||||
if len(events) == 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--events 至少包含一个事件种类")
|
||||
}
|
||||
if strings.TrimSpace(opts.QueryCSV) != "" {
|
||||
for _, eventName := range events {
|
||||
if eventName != "message" {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--query 只支持 message 事件")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
plan := listenIMPlan{}
|
||||
var err error
|
||||
switch kind {
|
||||
case "at-me", "all-direct", "all-group":
|
||||
if listenIMTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation(fmt.Sprintf("--kind %s 不接受用户或群目标", kind))
|
||||
}
|
||||
case "sender":
|
||||
if listenIMUserTargetCount(opts) != 1 || listenIMChatTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind sender 必须且只能指定 --user、--open-dingtalk-id 或 --user-query 之一")
|
||||
}
|
||||
plan.UserID = strings.TrimSpace(opts.UserID)
|
||||
plan.OpenDingTalkID = strings.TrimSpace(opts.OpenDingTalkID)
|
||||
if query := strings.TrimSpace(opts.UserQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveUser(reader, query, targetresolver.IdentityAny)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.UserID = resolved.Selected.UserID
|
||||
if plan.UserID == "" {
|
||||
plan.OpenDingTalkID = resolved.Selected.OpenDingTalkID
|
||||
}
|
||||
}
|
||||
case "group":
|
||||
if listenIMChatTargetCount(opts) != 1 || listenIMUserTargetCount(opts) != 0 {
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind group 必须且只能指定 --chat-id 或 --chat-query 之一")
|
||||
}
|
||||
plan.GroupID = strings.TrimSpace(opts.ChatID)
|
||||
if query := strings.TrimSpace(opts.ChatQuery); query != "" {
|
||||
resolved, resolveErr := targetresolver.ResolveChat(reader, query)
|
||||
if resolveErr != nil {
|
||||
return listenIMPlan{}, resolveErr
|
||||
}
|
||||
plan.ResolvedTargets = append(plan.ResolvedTargets, resolved)
|
||||
plan.GroupID = resolved.Selected.OpenConversationID
|
||||
}
|
||||
default:
|
||||
return listenIMPlan{}, apperrors.NewValidation("--kind 必须是 at-me、sender、group、all-direct 或 all-group")
|
||||
}
|
||||
|
||||
plan.EventKeys, err = listenIMEventKeys(kind, events)
|
||||
if err != nil {
|
||||
return listenIMPlan{}, err
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
func listenIMEventKeys(kind string, events []string) ([]string, error) {
|
||||
mapping := map[string]map[string]string{
|
||||
"at-me": {"message": personal.EventMention},
|
||||
"sender": {"message": personal.EventFromUser, "reaction": personal.EventReactionO2O, "read": personal.EventReadO2O, "recall": personal.EventRecallO2O},
|
||||
"group": {"message": personal.EventInChat, "reaction": personal.EventReactionGroup, "read": personal.EventReadGroup, "recall": personal.EventRecallGroup},
|
||||
"all-direct": {"message": personal.EventAllSingleChat},
|
||||
"all-group": {"message": personal.EventAllGroupChat},
|
||||
}
|
||||
byEvent := mapping[kind]
|
||||
keys := make([]string, 0, len(events))
|
||||
for _, eventName := range events {
|
||||
key := byEvent[eventName]
|
||||
if key == "" {
|
||||
return nil, apperrors.NewValidation(fmt.Sprintf("--kind %s 不支持 event %s", kind, eventName))
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys, nil
|
||||
}
|
||||
|
||||
func listenIMUserTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.UserID, opts.OpenDingTalkID, opts.UserQuery)
|
||||
}
|
||||
|
||||
func listenIMChatTargetCount(opts listenIMOptions) int {
|
||||
return nonEmptyListenIMCount(opts.ChatID, opts.ChatQuery)
|
||||
}
|
||||
|
||||
func listenIMTargetCount(opts listenIMOptions) int {
|
||||
return listenIMUserTargetCount(opts) + listenIMChatTargetCount(opts)
|
||||
}
|
||||
|
||||
func nonEmptyListenIMCount(values ...string) int {
|
||||
count := 0
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func uniqueListenIMValues(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := map[string]bool{}
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" || seen[value] {
|
||||
continue
|
||||
}
|
||||
seen[value] = true
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/shortcut/targetresolver"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type listenIMFakeReader struct {
|
||||
responses map[string]map[string]any
|
||||
calls []string
|
||||
}
|
||||
|
||||
type listenIMErrorReader struct{ err error }
|
||||
|
||||
func (r listenIMErrorReader) CallMCPData(string, string, map[string]any) (map[string]any, error) {
|
||||
return nil, r.err
|
||||
}
|
||||
|
||||
type listenIMHelperCaller struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) CallReadTool(context.Context, string, string, map[string]any) (*edition.ToolResult, error) {
|
||||
return c.result()
|
||||
}
|
||||
|
||||
func (c listenIMHelperCaller) result() (*edition.ToolResult, error) {
|
||||
if c.err != nil {
|
||||
return nil, c.err
|
||||
}
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: c.text}}}, nil
|
||||
}
|
||||
|
||||
func (listenIMHelperCaller) Format() string { return "json" }
|
||||
func (listenIMHelperCaller) DryRun() bool { return false }
|
||||
func (listenIMHelperCaller) Fields() string { return "" }
|
||||
func (listenIMHelperCaller) JQ() string { return "" }
|
||||
|
||||
func (f *listenIMFakeReader) CallMCPData(product, tool string, _ map[string]any) (map[string]any, error) {
|
||||
key := product + "/" + tool
|
||||
f.calls = append(f.calls, key)
|
||||
if response, ok := f.responses[key]; ok {
|
||||
return response, nil
|
||||
}
|
||||
return map[string]any{}, nil
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanResolvesGroupAndMapsMultipleEvents(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "group",
|
||||
Events: []string{"message", "reaction", "recall"},
|
||||
ChatQuery: "项目群",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantKeys := []string{personal.EventInChat, personal.EventReactionGroup, personal.EventRecallGroup}
|
||||
if !reflect.DeepEqual(plan.EventKeys, wantKeys) || plan.GroupID != "cid-1" {
|
||||
t.Fatalf("plan = %#v, want keys=%v group=cid-1", plan, wantKeys)
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanReturnsStructuredAmbiguityBeforeSubscription(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{
|
||||
map[string]any{"name": "张三", "userId": "u1"},
|
||||
map[string]any{"name": "张三", "userId": "u2"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
_, err := compileListenIMPlan(reader, listenIMOptions{
|
||||
Kind: "sender",
|
||||
Events: []string{"message"},
|
||||
UserQuery: "张三",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("ambiguous sender unexpectedly compiled")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "resolution_ambiguous" {
|
||||
t.Fatalf("ambiguity error = %#v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIMCommandDelegatesOneCompiledConsumeLifecycle(t *testing.T) {
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
var captured personalConsumeOptions
|
||||
var calls int
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
calls++
|
||||
captured = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group",
|
||||
"--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
"--max-events", "2",
|
||||
"--duration", "30s",
|
||||
"--dry-run",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 {
|
||||
t.Fatalf("consume lifecycle calls = %d, want 1", calls)
|
||||
}
|
||||
if !reflect.DeepEqual(captured.EventKeys, []string{personal.EventInChat, personal.EventReactionGroup}) ||
|
||||
captured.GroupID != "cid-1" || !captured.Flatten || !captured.Common.DryRun ||
|
||||
captured.Common.MaxEvents != 2 || captured.Common.Duration.String() != "30s" {
|
||||
t.Fatalf("captured options = %#v", captured)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageCompileListenIMPlanRejectsIncompatibleKindAndTargets(t *testing.T) {
|
||||
reader := &listenIMFakeReader{}
|
||||
cases := []listenIMOptions{
|
||||
{Kind: "at-me", Events: []string{"reaction"}},
|
||||
{Kind: "all-group", Events: []string{"message"}, ChatID: "cid"},
|
||||
{Kind: "sender", Events: []string{"message"}},
|
||||
{Kind: "group", Events: []string{"message"}, ChatID: "cid", ChatQuery: "群"},
|
||||
{Kind: "group", Events: []string{"message", "reaction"}, ChatID: "cid", QueryCSV: "关键词"},
|
||||
}
|
||||
for _, opts := range cases {
|
||||
if _, err := compileListenIMPlan(reader, opts); err == nil {
|
||||
t.Errorf("options unexpectedly accepted: %#v", opts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageListenIMCompletionBranches(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
text string
|
||||
err error
|
||||
ok bool
|
||||
}{
|
||||
{name: "transport", err: errors.New("transport")},
|
||||
{name: "empty", text: " ", ok: true},
|
||||
{name: "invalid json", text: "{invalid"},
|
||||
{name: "valid", text: `{"result":{"ok":true}}`, ok: true},
|
||||
} {
|
||||
t.Run("reader "+tc.name, func(t *testing.T) {
|
||||
helpers.InitDeps(listenIMHelperCaller{text: tc.text, err: tc.err})
|
||||
data, err := (eventTargetReader{}).CallMCPData("im", "search_groups", nil)
|
||||
if (err == nil) != tc.ok {
|
||||
t.Fatalf("data=%#v error=%v ok=%v", data, err, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if plan, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Events: []string{" MESSAGE ", "message"}}); err != nil || len(plan.EventKeys) != 1 {
|
||||
t.Fatalf("default/deduplicated plan = %#v, %v", plan, err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "at-me"}); err == nil {
|
||||
t.Fatal("empty event set unexpectedly accepted")
|
||||
}
|
||||
if _, err := compileListenIMPlan(&listenIMFakeReader{}, listenIMOptions{Kind: "unknown", Events: []string{"message"}}); err == nil {
|
||||
t.Fatal("unknown kind unexpectedly accepted")
|
||||
}
|
||||
|
||||
reader := &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"contact/search_contact_by_key_word": {
|
||||
"result": []any{map[string]any{"name": "甲", "openDingTalkId": "D-user"}},
|
||||
},
|
||||
}}
|
||||
plan, err := compileListenIMPlan(reader, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"})
|
||||
if err != nil || plan.UserID != "" || plan.OpenDingTalkID != "D-user" {
|
||||
t.Fatalf("open-id sender plan = %#v, %v", plan, err)
|
||||
}
|
||||
wantErr := errors.New("resolution failed")
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "sender", Events: []string{"message"}, UserQuery: "甲"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("sender resolution error = %v", err)
|
||||
}
|
||||
if _, err := compileListenIMPlan(listenIMErrorReader{err: wantErr}, listenIMOptions{Kind: "group", Events: []string{"message"}, ChatQuery: "群"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("group resolution error = %v", err)
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{"--kind", "sender"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "event +listen-im") {
|
||||
t.Fatalf("command compile error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventListenIME2ELifecycleCleansAndRollsBack(t *testing.T) {
|
||||
newReader := func() *listenIMFakeReader {
|
||||
return &listenIMFakeReader{responses: map[string]map[string]any{
|
||||
"im/search_groups": {
|
||||
"result": []any{map[string]any{"title": "项目群", "openConversationId": "cid-1"}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
installFacade := func(t *testing.T, reader *listenIMFakeReader) {
|
||||
t.Helper()
|
||||
oldReader := eventListenIMReader
|
||||
oldRun := eventRunPersonalConsume
|
||||
t.Cleanup(func() {
|
||||
eventListenIMReader = oldReader
|
||||
eventRunPersonalConsume = oldRun
|
||||
})
|
||||
eventListenIMReader = func() targetresolver.Reader { return reader }
|
||||
eventRunPersonalConsume = runPersonalEventConsume
|
||||
}
|
||||
installLifecycle := func(t *testing.T) {
|
||||
t.Helper()
|
||||
restore := installPersonalManySeams(t)
|
||||
t.Cleanup(restore)
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token", CorpID: "corp", UserID: "user",
|
||||
ClientID: "client", SourceID: "open",
|
||||
}, nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return nil }
|
||||
}
|
||||
|
||||
t.Run("ready then clean every created subscription", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
var created, deleted, removed []string
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
created = append(created, opts.EventKey)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "group", nil
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
||||
if len(specs) != 2 || !cfg.Flatten {
|
||||
t.Fatalf("consume specs/config = %#v / %#v", specs, cfg)
|
||||
}
|
||||
fmt.Fprintf(cfg.Stderr, "[event] ready event_count=%d bus_pid=123\n", len(specs))
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群", "--max-events", "1",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantEvents := []string{personal.EventInChat, personal.EventReactionGroup}
|
||||
if !reflect.DeepEqual(created, wantEvents) {
|
||||
t.Fatalf("created = %#v, want %#v", created, wantEvents)
|
||||
}
|
||||
wantDeleted := []string{"sub-" + personal.EventReactionGroup, "sub-" + personal.EventInChat}
|
||||
if !reflect.DeepEqual(deleted, wantDeleted) || !reflect.DeepEqual(removed, wantDeleted) {
|
||||
t.Fatalf("deleted=%#v removed=%#v want=%#v", deleted, removed, wantDeleted)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "[event] ready event_count=2") {
|
||||
t.Fatalf("missing ready marker: %s", stderr.String())
|
||||
}
|
||||
if !reflect.DeepEqual(reader.calls, []string{"im/search_groups"}) {
|
||||
t.Fatalf("resolver calls = %#v", reader.calls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("second create failure rolls back first without starting consumer", func(t *testing.T) {
|
||||
reader := newReader()
|
||||
installFacade(t, reader)
|
||||
installLifecycle(t)
|
||||
wantErr := errors.New("second subscription failed")
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "group", nil
|
||||
}
|
||||
var deleted, removed []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("consumer started after partial subscription failure")
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventListenIMCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SetArgs([]string{
|
||||
"--kind", "group", "--events", "message,reaction",
|
||||
"--chat-query", "项目群",
|
||||
})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), wantErr.Error()) {
|
||||
t.Fatalf("error = %v, want %v", err, wantErr)
|
||||
}
|
||||
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
||||
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,552 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/config"
|
||||
)
|
||||
|
||||
const personalSubscriptionAttemptOperation = "event.consume.personal.subscribe"
|
||||
|
||||
type personalSubscriptionAttemptStore interface {
|
||||
Claim([]personal.AttemptSpec, time.Duration) (*personal.AttemptClaim, error)
|
||||
CompleteSuccess(*personal.AttemptClaim) error
|
||||
CompleteFailure(*personal.AttemptClaim, []string, personal.AttemptFailure) (personal.AttemptHold, error)
|
||||
Release(*personal.AttemptClaim) error
|
||||
}
|
||||
|
||||
var (
|
||||
personalNewSubscriptionAttemptStore = func(workDir string) personalSubscriptionAttemptStore {
|
||||
return personal.NewAttemptStore(workDir)
|
||||
}
|
||||
personalSubscriptionAttemptNow = time.Now
|
||||
)
|
||||
|
||||
type personalSubscriptionAttemptItem struct {
|
||||
eventKey string
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
type personalSubscriptionAttemptReservation struct {
|
||||
store personalSubscriptionAttemptStore
|
||||
claim *personal.AttemptClaim
|
||||
items []personalSubscriptionAttemptItem
|
||||
}
|
||||
|
||||
type personalSubscriptionFailureClass struct {
|
||||
retryability personal.Retryability
|
||||
retryAfter time.Duration
|
||||
code string
|
||||
traceID string
|
||||
reason string
|
||||
auth bool
|
||||
}
|
||||
|
||||
func reservePersonalSubscriptionAttempts(
|
||||
workDir string,
|
||||
client *personal.Client,
|
||||
identity personal.Identity,
|
||||
profileSelector string,
|
||||
plans []personalConsumeOptions,
|
||||
) (*personalSubscriptionAttemptReservation, error) {
|
||||
if len(plans) == 0 {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: no subscription attempts to reserve"),
|
||||
)
|
||||
}
|
||||
if client == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil subscription control client"),
|
||||
)
|
||||
}
|
||||
if err := validatePersonalSubscriptionEndpoint(client.BaseURL); err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
|
||||
items := make([]personalSubscriptionAttemptItem, 0, len(plans))
|
||||
specs := make([]personal.AttemptSpec, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
prepared, err := preparePersonalSubscription(identity, plan)
|
||||
if err != nil {
|
||||
return nil, personalSubscriptionValidationError(err)
|
||||
}
|
||||
fingerprint := personal.Fingerprint(
|
||||
client.BaseURL,
|
||||
prepared.Request.IdempotencyKey,
|
||||
profileSelector,
|
||||
)
|
||||
items = append(items, personalSubscriptionAttemptItem{
|
||||
eventKey: prepared.EventKey,
|
||||
fingerprint: fingerprint,
|
||||
})
|
||||
specs = append(specs, personal.AttemptSpec{
|
||||
Fingerprint: fingerprint,
|
||||
EventKey: prepared.EventKey,
|
||||
})
|
||||
}
|
||||
|
||||
store := personalNewSubscriptionAttemptStore(workDir)
|
||||
if store == nil {
|
||||
return nil, personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt store is unavailable"),
|
||||
)
|
||||
}
|
||||
claim, err := store.Claim(specs, personalSubscriptionAttemptLease(client, len(specs)))
|
||||
if err != nil {
|
||||
var blocked *personal.AttemptBlockedError
|
||||
if errors.As(err, &blocked) {
|
||||
return nil, personalSubscriptionBlockedError(blocked)
|
||||
}
|
||||
return nil, personalSubscriptionGuardError(err)
|
||||
}
|
||||
return &personalSubscriptionAttemptReservation{
|
||||
store: store,
|
||||
claim: claim,
|
||||
items: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validatePersonalSubscriptionEndpoint(raw string) error {
|
||||
raw = strings.TrimSpace(raw)
|
||||
parsed, err := url.Parse(raw)
|
||||
if err != nil || parsed.Host == "" ||
|
||||
(!strings.EqualFold(parsed.Scheme, "http") &&
|
||||
!strings.EqualFold(parsed.Scheme, "https")) {
|
||||
if err == nil {
|
||||
err = errors.New("an absolute http(s) URL is required")
|
||||
}
|
||||
return fmt.Errorf("personal event: invalid subscription control endpoint %q: %w", raw, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func personalSubscriptionAttemptLease(client *personal.Client, batchSize int) time.Duration {
|
||||
const (
|
||||
leaseOverhead = 30 * time.Second
|
||||
minLease = time.Minute
|
||||
maxLease = 10 * time.Minute
|
||||
)
|
||||
if batchSize < 1 {
|
||||
batchSize = 1
|
||||
}
|
||||
timeout := config.HTTPTimeout
|
||||
if client != nil && client.HTTPClient != nil && client.HTTPClient.Timeout > 0 {
|
||||
timeout = client.HTTPClient.Timeout
|
||||
}
|
||||
maxRequestBudget := maxLease - leaseOverhead
|
||||
if timeout <= 0 || timeout > maxRequestBudget/time.Duration(batchSize) {
|
||||
return maxLease
|
||||
}
|
||||
lease := timeout*time.Duration(batchSize) + leaseOverhead
|
||||
if lease < minLease {
|
||||
return minLease
|
||||
}
|
||||
return lease
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeSuccess() error {
|
||||
if r == nil {
|
||||
return nil
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
)
|
||||
}
|
||||
if err := r.store.CompleteSuccess(r.claim); err != nil {
|
||||
return personalSubscriptionGuardError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *personalSubscriptionAttemptReservation) completeFailure(
|
||||
ctx context.Context,
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
if r == nil {
|
||||
return cause
|
||||
}
|
||||
if r.store == nil || r.claim == nil {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: subscription attempt reservation is incomplete"),
|
||||
))
|
||||
}
|
||||
if failedIndex < 0 || failedIndex >= len(r.items) ||
|
||||
succeededCount < 0 || succeededCount > failedIndex {
|
||||
return personalSubscriptionGuardError(errors.Join(
|
||||
cause,
|
||||
errors.New("personal event: invalid subscription attempt completion indexes"),
|
||||
))
|
||||
}
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
// Cancellation is not a failed attempt. Restoring the claim normally
|
||||
// completes immediately; if the lock cannot be acquired, leaving the
|
||||
// finite lease behind is still safer than recording a false failure.
|
||||
_ = r.store.Release(r.claim)
|
||||
return cause
|
||||
}
|
||||
|
||||
classification := classifyPersonalSubscriptionFailure(cause, personalSubscriptionAttemptNow())
|
||||
if override != nil {
|
||||
classification = *override
|
||||
}
|
||||
succeeded := make([]string, 0, succeededCount)
|
||||
for i := 0; i < succeededCount; i++ {
|
||||
succeeded = append(succeeded, r.items[i].fingerprint)
|
||||
}
|
||||
hold, err := r.store.CompleteFailure(r.claim, succeeded, personal.AttemptFailure{
|
||||
Fingerprint: r.items[failedIndex].fingerprint,
|
||||
Retryability: classification.retryability,
|
||||
RetryAfter: classification.retryAfter,
|
||||
ErrorCode: classification.code,
|
||||
TraceID: classification.traceID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionGuardError(errors.Join(cause, err))
|
||||
}
|
||||
return personalSubscriptionFailureError(cause, classification, hold)
|
||||
}
|
||||
|
||||
func personalSubscriptionCanceled(ctx context.Context, err error) bool {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return true
|
||||
}
|
||||
return ctx != nil && errors.Is(ctx.Err(), context.Canceled)
|
||||
}
|
||||
|
||||
func classifyPersonalSubscriptionFailure(err error, now time.Time) personalSubscriptionFailureClass {
|
||||
classification := personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_unknown",
|
||||
}
|
||||
|
||||
var apiErr *personal.APIError
|
||||
if errors.As(err, &apiErr) {
|
||||
classification.code = strings.TrimSpace(apiErr.Code)
|
||||
classification.traceID = strings.TrimSpace(apiErr.TraceID)
|
||||
classification.retryAfter = personalAPIRetryDelay(apiErr, now)
|
||||
classification.auth = personalSubscriptionAuthFailure(apiErr.HTTPStatus, apiErr.Code)
|
||||
switch {
|
||||
case apiErr.Retryable != nil && *apiErr.Retryable:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_server_retryable"
|
||||
case apiErr.Retryable != nil:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_server_non_retryable"
|
||||
case apiErr.HTTPStatus == http.StatusRequestTimeout ||
|
||||
apiErr.HTTPStatus == http.StatusTooEarly ||
|
||||
apiErr.HTTPStatus == http.StatusTooManyRequests ||
|
||||
apiErr.HTTPStatus >= http.StatusInternalServerError:
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_transient_http"
|
||||
case apiErr.HTTPStatus == http.StatusUnauthorized ||
|
||||
apiErr.HTTPStatus == http.StatusForbidden:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
case personalSubscriptionTerminalBusinessCode(apiErr.Code):
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_business_rejected"
|
||||
case personalSubscriptionErrorHasSubscribeID(apiErr):
|
||||
// A few legacy/proxy error shapes include an existing subscription
|
||||
// ID without a stable server contract. Keep the response as an
|
||||
// error, but do not turn that unverified shape into a one-hour hold.
|
||||
classification.reason = "personal_subscription_unverified_existing_id"
|
||||
case apiErr.HTTPStatus >= http.StatusBadRequest:
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_http_rejected"
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
if errors.Is(err, context.DeadlineExceeded) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_timeout"
|
||||
return classification
|
||||
}
|
||||
var urlErr *url.Error
|
||||
if errors.As(err, &urlErr) {
|
||||
if strings.EqualFold(strings.TrimSpace(urlErr.Op), "parse") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_invalid"
|
||||
return classification
|
||||
}
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
var netErr net.Error
|
||||
if errors.As(err, &netErr) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
if errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF) {
|
||||
classification.retryability = personal.RetryabilityRetryable
|
||||
classification.reason = "personal_subscription_network"
|
||||
return classification
|
||||
}
|
||||
lower := strings.ToLower(err.Error())
|
||||
if strings.Contains(lower, "access token") || strings.Contains(lower, "oauth") {
|
||||
classification.retryability = personal.RetryabilityNonRetryable
|
||||
classification.reason = "personal_subscription_auth"
|
||||
classification.auth = true
|
||||
}
|
||||
return classification
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorHasSubscribeID(apiErr *personal.APIError) bool {
|
||||
if apiErr == nil {
|
||||
return false
|
||||
}
|
||||
subscribeID, ok := apiErr.Details["subscribe_id"].(string)
|
||||
return ok && strings.TrimSpace(subscribeID) != ""
|
||||
}
|
||||
|
||||
func personalAPIRetryDelay(apiErr *personal.APIError, now time.Time) time.Duration {
|
||||
if apiErr == nil {
|
||||
return 0
|
||||
}
|
||||
var delay time.Duration
|
||||
if apiErr.RetryAfterSeconds != nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(*apiErr.RetryAfterSeconds))
|
||||
}
|
||||
if apiErr.NextRetryAt != nil {
|
||||
delay = maxPersonalRetryDelay(delay, apiErr.NextRetryAt.Sub(now))
|
||||
}
|
||||
if raw, ok := apiErr.Details["retry_after"].(string); ok {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if seconds, err := strconv.ParseInt(raw, 10, 64); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, personalRetrySeconds(seconds))
|
||||
} else if next, err := http.ParseTime(raw); err == nil {
|
||||
delay = maxPersonalRetryDelay(delay, next.Sub(now))
|
||||
}
|
||||
}
|
||||
return delay
|
||||
}
|
||||
|
||||
func personalRetrySeconds(seconds int64) time.Duration {
|
||||
if seconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
if seconds > math.MaxInt64/int64(time.Second) {
|
||||
return time.Duration(math.MaxInt64)
|
||||
}
|
||||
return time.Duration(seconds) * time.Second
|
||||
}
|
||||
|
||||
func maxPersonalRetryDelay(left, right time.Duration) time.Duration {
|
||||
if right > left {
|
||||
return right
|
||||
}
|
||||
return left
|
||||
}
|
||||
|
||||
func personalSubscriptionTerminalBusinessCode(raw string) bool {
|
||||
code := strings.ToUpper(strings.TrimSpace(raw))
|
||||
replacer := strings.NewReplacer("-", "_", ".", "_", " ", "_")
|
||||
code = replacer.Replace(code)
|
||||
|
||||
// Keep this list deliberately conservative. Unknown server codes must stay
|
||||
// unknown so a newly introduced transient condition cannot accidentally be
|
||||
// converted into a one-hour terminal hold.
|
||||
switch code {
|
||||
case "INVALID_PARAM", "INVALID_PARAMS", "INVALID_PARAMETER", "INVALID_PARAMETERS",
|
||||
"ILLEGAL_PARAM", "ILLEGAL_PARAMS", "ILLEGAL_PARAMETER", "ILLEGAL_PARAMETERS",
|
||||
"PARAM_ERROR", "PARAMETER_ERROR",
|
||||
"CLIENT_ID_REQUIRED", "SOURCE_ID_REQUIRED", "EVENT_KEY_REQUIRED", "RULE_TYPE_REQUIRED",
|
||||
"NO_AUTH", "NO_PERMISSION", "PERMISSION_DENIED", "ACCESS_DENIED",
|
||||
"FORBIDDEN", "UNAUTHORIZED",
|
||||
"NOT_FOUND", "NOT_EXIST", "NOT_SUPPORTED", "UNSUPPORTED",
|
||||
"UNIFIED_APP_ID_NOT_FOUND":
|
||||
return true
|
||||
}
|
||||
|
||||
// Resource-qualified variants are stable business-rejection shapes. Avoid
|
||||
// broad substring matching (for example, RETRY_REQUIRED must remain
|
||||
// unknown).
|
||||
for _, suffix := range []string{
|
||||
"_NOT_BELONG_TO_ORG",
|
||||
"_DOES_NOT_BELONG_TO_ORG",
|
||||
"_NOT_FOUND",
|
||||
"_NOT_EXIST",
|
||||
"_NOT_SUPPORTED",
|
||||
"_UNSUPPORTED",
|
||||
"_NO_PERMISSION",
|
||||
"_PERMISSION_DENIED",
|
||||
"_ACCESS_DENIED",
|
||||
} {
|
||||
if strings.HasSuffix(code, suffix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionAuthFailure(status int, rawCode string) bool {
|
||||
if status == http.StatusUnauthorized || status == http.StatusForbidden {
|
||||
return true
|
||||
}
|
||||
code := strings.ToUpper(strings.TrimSpace(rawCode))
|
||||
for _, marker := range []string{
|
||||
"NO_AUTH", "UNAUTHORIZED", "FORBIDDEN", "PERMISSION", "ACCESS_DENIED",
|
||||
} {
|
||||
if strings.Contains(code, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func personalSubscriptionFailureError(
|
||||
cause error,
|
||||
classification personalSubscriptionFailureClass,
|
||||
hold personal.AttemptHold,
|
||||
) error {
|
||||
options := personalSubscriptionErrorOptions(
|
||||
classification.retryability,
|
||||
hold.RetryAfter,
|
||||
hold.NextAllowedAt,
|
||||
classification.code,
|
||||
classification.traceID,
|
||||
classification.reason,
|
||||
cause,
|
||||
)
|
||||
message := cause.Error()
|
||||
if classification.retryability == personal.RetryabilityNonRetryable {
|
||||
if classification.auth {
|
||||
return apperrors.NewAuth(message, options...)
|
||||
}
|
||||
return apperrors.NewValidation(message, options...)
|
||||
}
|
||||
return apperrors.NewAPI(message, options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionBlockedError(blocked *personal.AttemptBlockedError) error {
|
||||
if blocked == nil {
|
||||
return personalSubscriptionGuardError(
|
||||
errors.New("personal event: nil blocked subscription attempt"),
|
||||
)
|
||||
}
|
||||
reason := "personal_subscription_" + string(blocked.State)
|
||||
options := personalSubscriptionErrorOptions(
|
||||
blocked.Retryability,
|
||||
blocked.RetryAfter,
|
||||
blocked.NextAllowedAt,
|
||||
blocked.ErrorCode,
|
||||
blocked.TraceID,
|
||||
reason,
|
||||
blocked,
|
||||
)
|
||||
if blocked.Retryability == personal.RetryabilityNonRetryable {
|
||||
if personalSubscriptionAuthFailure(0, blocked.ErrorCode) {
|
||||
return apperrors.NewAuth(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewValidation(blocked.Error(), options...)
|
||||
}
|
||||
return apperrors.NewAPI(blocked.Error(), options...)
|
||||
}
|
||||
|
||||
func personalSubscriptionErrorOptions(
|
||||
retryability personal.Retryability,
|
||||
retryAfter time.Duration,
|
||||
nextRetryAt time.Time,
|
||||
code string,
|
||||
traceID string,
|
||||
reason string,
|
||||
cause error,
|
||||
) []apperrors.Option {
|
||||
options := []apperrors.Option{
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason(reason),
|
||||
apperrors.WithCause(cause),
|
||||
}
|
||||
if retryable, known := retryability.Value(); known {
|
||||
options = append(options, apperrors.WithRetryable(retryable))
|
||||
}
|
||||
if retryAfter > 0 {
|
||||
options = append(options, apperrors.WithRetryAfterSeconds(ceilPersonalRetrySeconds(retryAfter)))
|
||||
}
|
||||
if !nextRetryAt.IsZero() {
|
||||
options = append(options, apperrors.WithNextRetryAt(nextRetryAt))
|
||||
}
|
||||
if code != "" || traceID != "" {
|
||||
options = append(options, apperrors.WithServerDiag(apperrors.ServerDiagnostics{
|
||||
TraceID: strings.TrimSpace(traceID),
|
||||
ServerErrorCode: strings.TrimSpace(code),
|
||||
}))
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func ceilPersonalRetrySeconds(delay time.Duration) int64 {
|
||||
if delay <= 0 {
|
||||
return 0
|
||||
}
|
||||
seconds := int64(delay / time.Second)
|
||||
if delay%time.Second != 0 {
|
||||
seconds++
|
||||
}
|
||||
return seconds
|
||||
}
|
||||
|
||||
func personalSubscriptionGuardError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: subscription attempt guard failed")
|
||||
}
|
||||
return apperrors.NewInternal(
|
||||
fmt.Sprintf("personal subscription attempt guard failed: %v", cause),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_guard_failed"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionValidationError(cause error) error {
|
||||
if cause == nil {
|
||||
cause = errors.New("personal event: invalid subscription parameters")
|
||||
}
|
||||
return apperrors.NewValidation(
|
||||
cause.Error(),
|
||||
apperrors.WithOperation(personalSubscriptionAttemptOperation),
|
||||
apperrors.WithReason("personal_subscription_invalid"),
|
||||
apperrors.WithRetryable(false),
|
||||
apperrors.WithCause(cause),
|
||||
)
|
||||
}
|
||||
|
||||
func personalSubscriptionLocalFailure() personalSubscriptionFailureClass {
|
||||
return personalSubscriptionFailureClass{
|
||||
retryability: personal.RetryabilityUnknown,
|
||||
reason: "personal_subscription_local_failure",
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,8 +29,11 @@ import (
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/helpers"
|
||||
|
||||
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/contract"
|
||||
dwsevent "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/bus"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
@@ -61,6 +64,7 @@ type commonConsumeOptions struct {
|
||||
type personalConsumeOptions struct {
|
||||
Common commonConsumeOptions
|
||||
EventKey string
|
||||
EventKeys []string
|
||||
Flatten bool
|
||||
DebugRawEvents bool
|
||||
SubscribeID string
|
||||
@@ -112,6 +116,7 @@ type personalStreamSourceOptions struct {
|
||||
|
||||
var (
|
||||
personalResolveEventIdentity = resolvePersonalEventIdentity
|
||||
personalLookupDefinition = personal.Lookup
|
||||
personalEnsureSubscription = ensurePersonalSubscription
|
||||
personalGetSubscription = (*personal.Client).GetSubscription
|
||||
personalCreateSubscription = (*personal.Client).CreateSubscription
|
||||
@@ -121,6 +126,7 @@ var (
|
||||
personalRemoveRunStates = personal.RemoveRunStates
|
||||
personalLoadRunStates = personal.LoadRunStates
|
||||
personalConsumeRun = consume.Run
|
||||
personalConsumeRunMany = consume.RunMany
|
||||
personalValidateConsumeConfig = consume.ValidateConfig
|
||||
personalValidateNoOutputConflict = consume.ValidateNoOutputConflict
|
||||
personalNewStreamSource = newPersonalStreamSource
|
||||
@@ -129,6 +135,7 @@ var (
|
||||
personalQueryEntry = busctl.QueryEntry
|
||||
personalQueryStatus = busctl.QueryStatus
|
||||
personalStopBus = busctl.Stop
|
||||
personalStopConsumers = busctl.StopConsumers
|
||||
personalFindProcess = os.FindProcess
|
||||
personalSignalProcess = (*os.Process).Signal
|
||||
personalResolveAuxiliaryAccessToken = ResolveAuxiliaryAccessToken
|
||||
@@ -166,13 +173,43 @@ func newEventSchemaCommand() *cobra.Command {
|
||||
cmd.Flags().StringVarP(&formatRaw, "format", "f", "json", "输出格式: json")
|
||||
cmd.Flags().BoolVar(&flatten, "flatten", false, "显示 --flatten 消费模式对应的顶层业务字段 schema")
|
||||
hideEventInternalFlags(cmd, "as")
|
||||
cli.AnnotateRuntimePositionals(cmd, cli.RuntimeSchemaPositional{
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "event_key",
|
||||
Type: "string",
|
||||
Description: "要查询 payload 字段定义的个人事件码",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "low",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "event",
|
||||
Name: "schema",
|
||||
CanonicalPath: "event.schema",
|
||||
CLIPath: "event schema",
|
||||
PrimaryCLIPath: "event schema",
|
||||
},
|
||||
Description: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "local",
|
||||
Availability: "available",
|
||||
Reason: "命令读取 CLI 内置的个人事件 payload 定义,不绑定 pinned MCP RPC",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "查询指定个人事件码的输出字段结构;Agent 应查询 --flatten 模式",
|
||||
UseWhen: []string{"已知任一公开个人 IM event_key,消费前需要理解输出字段或保守 payload 契约"},
|
||||
AvoidWhen: []string{
|
||||
"查询 CLI 命令参数契约时用顶层 dws schema",
|
||||
"要实际收事件时用 event consume",
|
||||
},
|
||||
Examples: []string{"dws event schema user_im_message_receive_at --flatten --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -206,9 +243,24 @@ func renderPersonalSchema(w io.Writer, def personal.Definition, format string, f
|
||||
}
|
||||
|
||||
func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
keys := dedupePersonalEventKeys(opts.EventKeys)
|
||||
if len(keys) == 0 && strings.TrimSpace(opts.EventKey) != "" {
|
||||
keys = []string{strings.TrimSpace(opts.EventKey)}
|
||||
}
|
||||
if len(keys) <= 1 {
|
||||
if len(keys) == 1 {
|
||||
opts.EventKey = keys[0]
|
||||
}
|
||||
return runPersonalEventConsumeSingle(c, opts)
|
||||
}
|
||||
opts.EventKeys = keys
|
||||
return runPersonalEventConsumeMany(c, opts)
|
||||
}
|
||||
|
||||
func runPersonalEventConsumeSingle(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
ctx := c.Context()
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return err
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
@@ -219,7 +271,7 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(opts.DebugRawEvents, opts.Flatten)
|
||||
|
||||
@@ -232,22 +284,23 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
if err := validatePersonalSubscriptionOptions(opts); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
}
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir)),
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
@@ -264,16 +317,100 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
DryRun: true,
|
||||
}
|
||||
applyPersonalConsumeFilters(&cfg, opts, strings.TrimSpace(opts.SubscribeID), opts.EventKey)
|
||||
return personalConsumeRun(ctx, cfg)
|
||||
if err := personalConsumeRun(ctx, cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL, spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: opts.EventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Complete all local validation before creating a remote subscription.
|
||||
// Otherwise an invalid output mode can repeatedly create and roll back a
|
||||
// valid subscription when an outer agent relaunches the command.
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
var foregroundSource *source.PersonalSource
|
||||
if opts.Common.Foreground {
|
||||
foregroundSource, err = personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
var attempt *personalSubscriptionAttemptReservation
|
||||
if strings.TrimSpace(opts.SubscribeID) == "" {
|
||||
attempt, err = reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
[]personalConsumeOptions{opts},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
}
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, opts)
|
||||
if err != nil {
|
||||
err = attempt.completeFailure(ctx, 0, 0, err, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if sub.SubscribeID == "" {
|
||||
return fmt.Errorf("event consume --as user: server returned empty subscribe_id")
|
||||
if sub == nil {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned an empty subscription"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(sub.SubscribeID) == "" {
|
||||
err = attempt.completeFailure(
|
||||
ctx,
|
||||
0,
|
||||
0,
|
||||
errors.New("personal event: server returned empty subscribe_id"),
|
||||
nil,
|
||||
)
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
_ = personalDeleteSubscription(client, cleanupCtx, sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
}
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: sub.SubscribeID,
|
||||
@@ -283,11 +420,21 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("event consume --as user: save run state: %w", err)
|
||||
wrapped := fmt.Errorf("save run state: %w", err)
|
||||
if attempt != nil {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, wrapped) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
wrapped = attempt.completeFailure(ctx, 0, 0, wrapped, &classification)
|
||||
cleanup(cleanupCtx)
|
||||
}
|
||||
return fmt.Errorf("event consume --as user: %w", wrapped)
|
||||
}
|
||||
cleanup := func() {
|
||||
_ = personalDeleteSubscription(client, context.Background(), sub.SubscribeID)
|
||||
_ = personalRemoveRunStates(workDir, []string{sub.SubscribeID})
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
// Ownership-based cleanup: a subscription this run CREATED is
|
||||
// unsubscribed on exit
|
||||
@@ -297,59 +444,17 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
// either way.
|
||||
selfCreated := strings.TrimSpace(opts.SubscribeID) == ""
|
||||
if opts.Ephemeral || selfCreated {
|
||||
defer cleanup()
|
||||
defer cleanup(context.Background())
|
||||
}
|
||||
|
||||
cfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, opts.StreamTicketURL),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
EventKey: eventKey,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
ReadySubscribeID: sub.SubscribeID,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
Foreground: opts.Common.Foreground,
|
||||
Force: opts.Common.Force,
|
||||
}
|
||||
// Arm the stdin-EOF shutdown watcher only for a pipe-style, unbounded
|
||||
// run (see shouldWatchStdinEOF).
|
||||
applyEventConsumeStdin(&cfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
cfg.EventKey = eventKey
|
||||
cfg.ReadySubscribeID = sub.SubscribeID
|
||||
applyPersonalConsumeFilters(&cfg, opts, sub.SubscribeID, eventKey)
|
||||
if opts.DebugRawEvents && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "debug raw events enabled: local event filters disabled\nworkdir: %s\nbus_log: %s\n",
|
||||
workDir, filepath.Join(workDir, "bus.log"))
|
||||
}
|
||||
if err := personalValidateConsumeConfig(cfg); err != nil {
|
||||
return err
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(cfg, o.Value.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.Common.Foreground {
|
||||
src, err := personalNewStreamSource(ctx, personalStreamSourceOptions{
|
||||
ConfigDir: configDir,
|
||||
Identity: identity,
|
||||
TicketMode: opts.StreamTicketMode,
|
||||
TicketURL: opts.StreamTicketURL,
|
||||
})
|
||||
if err != nil {
|
||||
if !opts.Ephemeral {
|
||||
cleanup()
|
||||
}
|
||||
return err
|
||||
}
|
||||
busCfg := bus.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
@@ -358,22 +463,318 @@ func runPersonalEventConsume(c *cobra.Command, opts personalConsumeOptions) erro
|
||||
SourceKind: dwsevent.SourceKindPersonalStream,
|
||||
IdentityHash: identityHash,
|
||||
SourceID: identity.SourceID,
|
||||
Source: src,
|
||||
Source: foregroundSource,
|
||||
}
|
||||
bus.ApplyEnvTuning(&busCfg)
|
||||
err = personalBusRun(ctx, busCfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
err = personalConsumeRun(ctx, cfg)
|
||||
if err != nil && !opts.Ephemeral {
|
||||
cleanup()
|
||||
cleanup(context.Background())
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type personalMultiSubscription struct {
|
||||
Sub *personal.Subscription
|
||||
EventKey string
|
||||
RuleType string
|
||||
}
|
||||
|
||||
func runPersonalEventConsumeMany(c *cobra.Command, opts personalConsumeOptions) error {
|
||||
plans, err := preparePersonalMultiOptions(opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
rawFormat := ""
|
||||
if f := c.Flags().Lookup("format"); f != nil && f.Changed {
|
||||
rawFormat = opts.Common.FormatRaw
|
||||
}
|
||||
normalised, fellback := consume.NormalizeFormat(rawFormat)
|
||||
if fellback && !opts.Common.Quiet {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: --format %q has no meaning for event stream; using ndjson\n", rawFormat)
|
||||
}
|
||||
if err := validatePersonalEventOutputMode(opts.Flatten, opts.DebugRawEvents, normalised); err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
projector := personalEventProjector(false, opts.Flatten)
|
||||
|
||||
ctx := c.Context()
|
||||
configDir := defaultConfigDir()
|
||||
identity, err := personalResolveEventIdentity(ctx, configDir, opts.StreamSourceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
identityHash := dwsevent.IdentityHash(identity.Key())
|
||||
editionName := editionNameOrDefault()
|
||||
workDir := eventWorkDir(configDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
ipcEndpoint := defaultIPCEndpoint(workDir, editionName, dwsevent.SourceKindPersonalStream, identityHash)
|
||||
spawnProfileSelector := personalBusProfileSelector(configDir, identity)
|
||||
routes, err := consume.ParseRoutes(opts.Common.RoutesRaw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", personalSubscriptionValidationError(err))
|
||||
}
|
||||
baseCfg := consume.Config{
|
||||
WorkDir: workDir,
|
||||
IPCEndpoint: ipcEndpoint,
|
||||
ClientID: identity.ClientID,
|
||||
SpawnExtraArgs: personalBusSpawnArgs(identity, opts.StreamTicketMode, personalEventStreamTicketURL(opts.StreamTicketURL, configDir), spawnProfileSelector),
|
||||
Compact: opts.Common.Compact,
|
||||
MaxEvents: opts.Common.MaxEvents,
|
||||
Duration: opts.Common.Duration,
|
||||
Format: normalised,
|
||||
Flatten: opts.Flatten,
|
||||
OutputDir: opts.Common.OutputDir,
|
||||
Routes: routes,
|
||||
Projector: projector,
|
||||
Stdout: c.OutOrStdout(),
|
||||
Stderr: c.ErrOrStderr(),
|
||||
Quiet: opts.Common.Quiet,
|
||||
}
|
||||
applyEventConsumeStdin(&baseCfg, opts.Common.MaxEvents, opts.Common.Duration, c.InOrStdin())
|
||||
if err := personalValidateConsumeConfig(baseCfg); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
if o := c.Flags().Lookup("output"); o != nil && o.Changed {
|
||||
if err := personalValidateNoOutputConflict(baseCfg, o.Value.String()); err != nil {
|
||||
return personalSubscriptionValidationError(err)
|
||||
}
|
||||
}
|
||||
if opts.Common.DryRun {
|
||||
printPersonalMultiDryRun(c.ErrOrStderr(), baseCfg, plans)
|
||||
return nil
|
||||
}
|
||||
|
||||
client := newPersonalEventControlClient(configDir, personalEventControlBaseURL(opts.ControlBaseURL, configDir), identity)
|
||||
attempt, err := reservePersonalSubscriptionAttempts(
|
||||
workDir,
|
||||
client,
|
||||
identity,
|
||||
spawnProfileSelector,
|
||||
plans,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
created := make([]personalMultiSubscription, 0, len(plans))
|
||||
cleanup := func(cleanupCtx context.Context) {
|
||||
ids := make([]string, 0, len(created))
|
||||
for i := len(created) - 1; i >= 0; i-- {
|
||||
id := strings.TrimSpace(created[i].Sub.SubscribeID)
|
||||
ids = append(ids, id)
|
||||
if err := personalDeleteSubscription(client, cleanupCtx, id); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal subscription %s: %v\n", id, err)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
if err := personalRemoveRunStates(workDir, ids); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to clean personal event run state: %v\n", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
failAndCleanup := func(
|
||||
failedIndex int,
|
||||
succeededCount int,
|
||||
cause error,
|
||||
override *personalSubscriptionFailureClass,
|
||||
) error {
|
||||
cleanupCtx := context.Background()
|
||||
if personalSubscriptionCanceled(ctx, cause) {
|
||||
cleanupCtx = ctx
|
||||
}
|
||||
completed := attempt.completeFailure(ctx, failedIndex, succeededCount, cause, override)
|
||||
// Persist the hold (or release a canceled claim) before any potentially
|
||||
// slow remote rollback. Otherwise the attempt lease can expire while
|
||||
// deleting earlier subscriptions and admit a duplicate create batch.
|
||||
cleanup(cleanupCtx)
|
||||
return completed
|
||||
}
|
||||
seenSubscribeIDs := make(map[string]struct{}, len(plans))
|
||||
for i, plan := range plans {
|
||||
sub, eventKey, ruleType, err := personalEnsureSubscription(ctx, client, identity, plan)
|
||||
if err != nil {
|
||||
err = failAndCleanup(i, len(created), err, nil)
|
||||
return fmt.Errorf("event consume --as user: create subscription for %s: %w", plan.EventKey, err)
|
||||
}
|
||||
if sub == nil {
|
||||
cause := fmt.Errorf("personal event: server returned an empty subscription for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
id := strings.TrimSpace(sub.SubscribeID)
|
||||
if id == "" {
|
||||
cause := fmt.Errorf("personal event: server returned empty subscribe_id for %s", plan.EventKey)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
if _, exists := seenSubscribeIDs[id]; exists {
|
||||
cause := fmt.Errorf("personal event: server returned duplicate subscribe_id %s", id)
|
||||
cause = failAndCleanup(i, len(created), cause, nil)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
seenSubscribeIDs[id] = struct{}{}
|
||||
item := personalMultiSubscription{Sub: sub, EventKey: eventKey, RuleType: ruleType}
|
||||
created = append(created, item)
|
||||
if err := personalUpsertRunState(workDir, personal.RunState{
|
||||
SubscribeID: id,
|
||||
EventKey: eventKey,
|
||||
RuleType: ruleType,
|
||||
ClientID: identity.ClientID,
|
||||
SourceID: identity.SourceID,
|
||||
IdentityHash: identityHash,
|
||||
}); err != nil {
|
||||
cause := fmt.Errorf("save run state for %s: %w", eventKey, err)
|
||||
classification := personalSubscriptionLocalFailure()
|
||||
cause = failAndCleanup(i, len(created)-1, cause, &classification)
|
||||
return fmt.Errorf("event consume --as user: %w", cause)
|
||||
}
|
||||
}
|
||||
if err := attempt.completeSuccess(); err != nil {
|
||||
cleanup(context.Background())
|
||||
return fmt.Errorf("event consume --as user: %w", err)
|
||||
}
|
||||
defer cleanup(context.Background())
|
||||
|
||||
specs := make([]consume.ConsumerSpec, 0, len(created))
|
||||
for _, item := range created {
|
||||
specs = append(specs, consume.ConsumerSpec{
|
||||
EventKey: item.EventKey,
|
||||
EventTypes: []string{item.EventKey},
|
||||
SubscribeID: item.Sub.SubscribeID,
|
||||
ReadySubscribeID: item.Sub.SubscribeID,
|
||||
})
|
||||
}
|
||||
if err := personalConsumeRunMany(ctx, baseCfg, specs); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func preparePersonalMultiOptions(opts personalConsumeOptions) ([]personalConsumeOptions, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
return nil, errors.New("--subscribe-id is not supported when consuming multiple events")
|
||||
}
|
||||
if strings.TrimSpace(opts.Rule) != "" {
|
||||
return nil, errors.New("--rule is not supported when consuming multiple events")
|
||||
}
|
||||
if len(opts.Common.EventTypes) > 0 {
|
||||
return nil, errors.New("--event-types is not supported when consuming multiple events; use event_key positionals")
|
||||
}
|
||||
if strings.TrimSpace(opts.Common.Filter) != "" {
|
||||
return nil, errors.New("--filter is not supported when consuming multiple events; use event_key positionals")
|
||||
}
|
||||
if opts.Common.Foreground || opts.Common.Force {
|
||||
return nil, errors.New("--foreground/--force are not supported when consuming multiple events")
|
||||
}
|
||||
if opts.DebugRawEvents {
|
||||
return nil, errors.New("--debug-raw-events is not supported when consuming multiple events")
|
||||
}
|
||||
|
||||
keys := dedupePersonalEventKeys(opts.EventKeys)
|
||||
if len(keys) < 2 {
|
||||
return nil, errors.New("multiple event keys are required")
|
||||
}
|
||||
hasUserScope := false
|
||||
hasGroupScope := false
|
||||
for _, eventKey := range keys {
|
||||
def, ok := personalLookupDefinition(eventKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unknown personal event key %q", eventKey)
|
||||
}
|
||||
if !def.Public {
|
||||
return nil, personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
switch def.RuleType {
|
||||
case "singleChat", "sender":
|
||||
hasUserScope = true
|
||||
case "group":
|
||||
hasGroupScope = true
|
||||
}
|
||||
if (strings.TrimSpace(opts.QueryCSV) != "" || strings.TrimSpace(opts.FilterJSON) != "") && !personal.SupportsMessageFilter(eventKey) {
|
||||
return nil, fmt.Errorf("--query/--filter-json require all selected events to be message receive events; %s is not", eventKey)
|
||||
}
|
||||
}
|
||||
if hasUserScope && hasGroupScope {
|
||||
return nil, errors.New("user-scoped and group-scoped events cannot be consumed in one command")
|
||||
}
|
||||
userID := strings.TrimSpace(opts.UserID)
|
||||
openID := strings.TrimSpace(opts.OpenDingTalkID)
|
||||
groupID := strings.TrimSpace(opts.GroupID)
|
||||
if userID != "" && openID != "" {
|
||||
return nil, errors.New("--user and --open-dingtalk-id are mutually exclusive")
|
||||
}
|
||||
switch {
|
||||
case hasUserScope:
|
||||
if groupID != "" {
|
||||
return nil, errors.New("--group cannot be used with user-scoped events")
|
||||
}
|
||||
if userID == "" && openID == "" {
|
||||
return nil, errors.New("one of --user or --open-dingtalk-id is required for the selected events")
|
||||
}
|
||||
case hasGroupScope:
|
||||
if userID != "" || openID != "" {
|
||||
return nil, errors.New("--user/--open-dingtalk-id cannot be used with group-scoped events")
|
||||
}
|
||||
if groupID == "" {
|
||||
return nil, errors.New("--group is required for the selected events")
|
||||
}
|
||||
default:
|
||||
if userID != "" || openID != "" || groupID != "" {
|
||||
return nil, errors.New("the selected events do not use --user, --open-dingtalk-id, or --group")
|
||||
}
|
||||
}
|
||||
|
||||
plans := make([]personalConsumeOptions, 0, len(keys))
|
||||
for _, eventKey := range keys {
|
||||
def, _ := personalLookupDefinition(eventKey)
|
||||
plan := opts
|
||||
plan.EventKey = eventKey
|
||||
plan.EventKeys = nil
|
||||
switch def.RuleType {
|
||||
case "at", "all":
|
||||
plan.UserID = ""
|
||||
plan.OpenDingTalkID = ""
|
||||
plan.GroupID = ""
|
||||
case "singleChat", "sender":
|
||||
plan.GroupID = ""
|
||||
case "group":
|
||||
plan.UserID = ""
|
||||
plan.OpenDingTalkID = ""
|
||||
}
|
||||
if err := validatePersonalSubscriptionOptions(plan); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plans = append(plans, plan)
|
||||
}
|
||||
return plans, nil
|
||||
}
|
||||
|
||||
func printPersonalMultiDryRun(w io.Writer, cfg consume.Config, plans []personalConsumeOptions) {
|
||||
preview := cfg
|
||||
preview.EventTypes = make([]string, 0, len(plans))
|
||||
for _, plan := range plans {
|
||||
preview.EventTypes = append(preview.EventTypes, plan.EventKey)
|
||||
}
|
||||
consume.PrintDryRun(w, preview)
|
||||
for i, plan := range plans {
|
||||
ruleType, ruleParam, _ := personal.BuildRuleParam(plan.EventKey, personal.RuleOptions{
|
||||
UserID: plan.UserID, OpenDingTalkID: plan.OpenDingTalkID, GroupID: plan.GroupID,
|
||||
})
|
||||
_, filter, _ := personal.BuildFilter(plan.FilterJSON, plan.QueryCSV)
|
||||
ruleJSON, _ := personal.CanonicalJSON(ruleParam)
|
||||
fmt.Fprintf(w, " subscription[%d] : event_key=%s rule_type=%s rule_param=%s",
|
||||
i, plan.EventKey, ruleType, ruleJSON)
|
||||
if filter != "" {
|
||||
fmt.Fprintf(w, " filter=%s", filter)
|
||||
}
|
||||
fmt.Fprintln(w)
|
||||
}
|
||||
}
|
||||
|
||||
func personalEventProjector(debugRawEvents, flatten bool) consume.Projector {
|
||||
if debugRawEvents {
|
||||
return func(ev transport.Event) (any, error) { return ev, nil }
|
||||
@@ -425,6 +826,59 @@ func validatePersonalSubscriptionOptions(opts personalConsumeOptions) error {
|
||||
return err
|
||||
}
|
||||
|
||||
type personalPreparedSubscription struct {
|
||||
EventKey string
|
||||
RuleType string
|
||||
Request personal.CreateSubscriptionRequest
|
||||
}
|
||||
|
||||
func preparePersonalSubscription(identity personal.Identity, opts personalConsumeOptions) (personalPreparedSubscription, error) {
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return personalPreparedSubscription{}, fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return personalPreparedSubscription{}, err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
return personalPreparedSubscription{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Request: req,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func createPreparedPersonalSubscription(ctx context.Context, client *personal.Client, plan personalPreparedSubscription) (*personal.Subscription, string, string, error) {
|
||||
sub, err := personalCreateSubscription(client, ctx, plan.Request)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, plan.EventKey, plan.RuleType, nil
|
||||
}
|
||||
|
||||
func ensurePersonalSubscription(ctx context.Context, client *personal.Client, identity personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
if strings.TrimSpace(opts.SubscribeID) != "" {
|
||||
sub, err := personalGetSubscription(client, ctx, opts.SubscribeID)
|
||||
@@ -447,42 +901,11 @@ func ensurePersonalSubscription(ctx context.Context, client *personal.Client, id
|
||||
sub.SubscribeID = strings.TrimSpace(opts.SubscribeID)
|
||||
return sub, eventKey, ruleType, nil
|
||||
}
|
||||
if strings.TrimSpace(opts.EventKey) == "" {
|
||||
return nil, "", "", fmt.Errorf("event_key is required unless --subscribe-id is provided")
|
||||
}
|
||||
if err := ensurePublicPersonalEvent(opts.EventKey); err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
ruleType, ruleParam, err := personal.BuildRuleParam(opts.EventKey, personal.RuleOptions{
|
||||
RuleType: opts.Rule,
|
||||
UserID: opts.UserID,
|
||||
OpenDingTalkID: opts.OpenDingTalkID,
|
||||
GroupID: opts.GroupID,
|
||||
})
|
||||
plan, err := preparePersonalSubscription(identity, opts)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
filter, filterCanonical, err := personal.BuildFilter(opts.FilterJSON, opts.QueryCSV)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
req := personal.CreateSubscriptionRequest{
|
||||
EventKey: opts.EventKey,
|
||||
RuleType: ruleType,
|
||||
Name: opts.Name,
|
||||
RuleParam: ruleParam,
|
||||
Filter: filter,
|
||||
Delivery: map[string]any{"mode": "stream"},
|
||||
IdempotencyKey: personal.IdempotencyKey(identity, opts.EventKey, ruleType, ruleParam, filterCanonical),
|
||||
}
|
||||
if opts.TTL > 0 {
|
||||
req.TTLSeconds = int64(opts.TTL.Seconds())
|
||||
}
|
||||
sub, err := personalCreateSubscription(client, ctx, req)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
}
|
||||
return sub, opts.EventKey, ruleType, nil
|
||||
return createPreparedPersonalSubscription(ctx, client, plan)
|
||||
}
|
||||
|
||||
func runPersonalEventStatus(c *cobra.Command, opts personalStatusOptions) error {
|
||||
@@ -549,7 +972,7 @@ func ensurePublicPersonalEvent(eventKey string) error {
|
||||
if eventKey == "" {
|
||||
return nil
|
||||
}
|
||||
if def, ok := personal.Lookup(eventKey); ok && !def.Public {
|
||||
if def, ok := personalLookupDefinition(eventKey); ok && !def.Public {
|
||||
return personal.PublicAvailabilityError(eventKey)
|
||||
}
|
||||
return nil
|
||||
@@ -647,7 +1070,7 @@ func runPersonalEventStop(c *cobra.Command, opts personalStopOptions) error {
|
||||
if err := personalRemoveRunStates(workDir, subscribeIDs); err != nil {
|
||||
return fmt.Errorf("event stop --as user: update local state: %w", err)
|
||||
}
|
||||
if err := interruptPersonalConsumers(ipcEndpoint, subscribeIDs); err != nil {
|
||||
if err := stopPersonalConsumers(c.ErrOrStderr(), ipcEndpoint, subscribeIDs); err != nil {
|
||||
fmt.Fprintf(c.ErrOrStderr(), "WARN: failed to stop matching local consume process: %v\n", err)
|
||||
}
|
||||
|
||||
@@ -735,6 +1158,17 @@ func interruptPersonalConsumers(ipcEndpoint string, subscribeIDs []string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
func stopPersonalConsumers(w io.Writer, ipcEndpoint string, subscribeIDs []string) error {
|
||||
if _, err := personalStopConsumers(ipcEndpoint, subscribeIDs); err == nil {
|
||||
return nil
|
||||
} else if !errors.Is(err, busctl.ErrConsumerStopUnsupported) {
|
||||
return err
|
||||
} else {
|
||||
fmt.Fprintf(w, "WARN: running bus does not support targeted consumer stop; falling back to process signal: %v\n", err)
|
||||
}
|
||||
return interruptPersonalConsumers(ipcEndpoint, subscribeIDs)
|
||||
}
|
||||
|
||||
func printPersonalStopResult(w io.Writer, subscribeIDs []string, single bool, busState string) {
|
||||
if single && len(subscribeIDs) == 1 {
|
||||
fmt.Fprintf(w, "cancelled personal subscription %s; %s\n", subscribeIDs[0], busState)
|
||||
@@ -800,6 +1234,12 @@ func resolvePersonalEventIdentity(ctx context.Context, configDir string, sourceI
|
||||
func newPersonalEventControlClient(configDir, baseURL string, identity personal.Identity) *personal.Client {
|
||||
identity.AccessToken = ""
|
||||
client := personal.NewClient(baseURL, identity)
|
||||
version := strings.TrimSpace(RawVersion())
|
||||
if version == "" {
|
||||
version = "unknown"
|
||||
}
|
||||
client.ClientVersion = version
|
||||
client.UserAgent = "dws-cli/" + version
|
||||
client.AccessTokenProvider = func(ctx context.Context) (string, error) {
|
||||
return personalResolveAuxiliaryAccessToken(ctx, configDir, "")
|
||||
}
|
||||
@@ -869,7 +1309,44 @@ func newPersonalStreamSource(ctx context.Context, opts personalStreamSourceOptio
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string) []string {
|
||||
func personalBusProfileSelector(configDir string, identity personal.Identity) string {
|
||||
// The parent already resolved and loaded this selector. Preserve it before
|
||||
// consulting identity metadata: personal event discovery can fill an empty
|
||||
// token userId from runtime defaults, and that inferred value must not turn a
|
||||
// historical unresolved account into a different exact same-corp account in
|
||||
// the detached child.
|
||||
if selector := strings.TrimSpace(authpkg.RuntimeProfile()); selector != "" {
|
||||
return selector
|
||||
}
|
||||
if cfg, err := authpkg.LoadProfiles(configDir); err == nil && cfg != nil {
|
||||
// With no explicit process-local override, LoadTokenData selected the
|
||||
// persisted current profile. Prefer that selection over the enriched
|
||||
// identity: $currentUserId may describe an exact same-corp account even
|
||||
// though the token came from the historical unresolved profile.
|
||||
currentSelector := strings.TrimSpace(cfg.CurrentProfile)
|
||||
for i := range cfg.Profiles {
|
||||
profile := cfg.Profiles[i]
|
||||
selector := authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
if selector == currentSelector &&
|
||||
(strings.TrimSpace(identity.CorpID) == "" || strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID)) {
|
||||
return selector
|
||||
}
|
||||
}
|
||||
for i := range cfg.Profiles {
|
||||
profile := cfg.Profiles[i]
|
||||
if strings.TrimSpace(profile.CorpID) == strings.TrimSpace(identity.CorpID) &&
|
||||
strings.TrimSpace(profile.UserID) == strings.TrimSpace(identity.UserID) {
|
||||
return authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
}
|
||||
}
|
||||
}
|
||||
return authpkg.ProfileSelector(authpkg.Profile{
|
||||
CorpID: identity.CorpID,
|
||||
UserID: identity.UserID,
|
||||
})
|
||||
}
|
||||
|
||||
func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL string, profileSelectors ...string) []string {
|
||||
args := []string{
|
||||
"--source-kind", string(dwsevent.SourceKindPersonalStream),
|
||||
"--stream-source-id", identity.SourceID,
|
||||
@@ -878,10 +1355,11 @@ func personalBusSpawnArgs(identity personal.Identity, ticketMode, ticketURL stri
|
||||
// credentials as the parent, including when one organization has multiple
|
||||
// logged-in users.
|
||||
if cid := strings.TrimSpace(identity.CorpID); cid != "" {
|
||||
args = append(args, "--profile", authpkg.ProfileSelector(authpkg.Profile{
|
||||
CorpID: identity.CorpID,
|
||||
UserID: identity.UserID,
|
||||
}))
|
||||
profileSelector := authpkg.ProfileSelector(authpkg.Profile{CorpID: identity.CorpID, UserID: identity.UserID})
|
||||
if len(profileSelectors) > 0 && strings.TrimSpace(profileSelectors[0]) != "" {
|
||||
profileSelector = strings.TrimSpace(profileSelectors[0])
|
||||
}
|
||||
args = append(args, "--profile", profileSelector)
|
||||
}
|
||||
if strings.TrimSpace(ticketMode) != "" {
|
||||
args = append(args, "--stream-ticket-mode", ticketMode)
|
||||
@@ -929,6 +1407,23 @@ func firstNonEmptyPersonalString(values ...string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func dedupePersonalEventKeys(values []string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func personalEventControlBaseURL(raw, configDir string) string {
|
||||
if v := strings.TrimSpace(raw); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
@@ -954,7 +1449,7 @@ func personalEventMCPBaseURL(configDir string) string {
|
||||
if v := configuredMCPBaseURL(configDir); v != "" {
|
||||
return strings.TrimRight(v, "/")
|
||||
}
|
||||
return config.DefaultMCPBaseURL
|
||||
return strings.TrimRight(config.DefaultMCPBaseURL, "/")
|
||||
}
|
||||
|
||||
func configuredMCPBaseURL(configDir string) string {
|
||||
|
||||
@@ -77,6 +77,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingSchemaAndSubscriptionCoverag
|
||||
func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T) {
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
@@ -88,6 +89,7 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
t.Cleanup(func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
@@ -97,6 +99,9 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = oldNewSource
|
||||
personalBusRun = oldBusRun
|
||||
})
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
|
||||
wantErr := errors.New("consume")
|
||||
cmd := newPersonalCoverageCommand()
|
||||
@@ -154,11 +159,11 @@ func TestCrossPlatformCoveragePersonalEventRemainingConsumeCoverage(t *testing.T
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) {
|
||||
return nil, wantErr
|
||||
}
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == 0 {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != 0 {
|
||||
t.Fatalf("foreground source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
before := deletes
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes == before {
|
||||
if err := runPersonalEventConsume(cmd, personalConsumeOptions{EventKey: personal.EventMention, Ephemeral: true, Common: commonConsumeOptions{Foreground: true}}); !errors.Is(err, wantErr) || deletes != before {
|
||||
t.Fatalf("ephemeral source error = %v deletes=%d", err, deletes)
|
||||
}
|
||||
personalNewStreamSource = func(context.Context, personalStreamSourceOptions) (*source.PersonalSource, error) { return nil, nil }
|
||||
|
||||
@@ -0,0 +1,883 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/busctl"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/consume"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/transport"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventConsumeAcceptsOrderedVariadicEventKeys(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
var got personalConsumeOptions
|
||||
eventRunPersonalConsume = func(_ *cobra.Command, opts personalConsumeOptions) error {
|
||||
got = opts
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventMention,
|
||||
"--user", "test-user-001",
|
||||
})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
want := []string{personal.EventMention, personal.EventSingleChat}
|
||||
if !reflect.DeepEqual(got.EventKeys, want) || got.EventKey != personal.EventMention {
|
||||
t.Fatalf("event keys = %#v, first = %q", got.EventKeys, got.EventKey)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsCombinationMatrix(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts personalConsumeOptions
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no target events",
|
||||
opts: personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}},
|
||||
},
|
||||
{
|
||||
name: "user and no target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O, personal.EventMention},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "group and no target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated, personal.EventMention},
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "open dingtalk id",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventRecallO2O},
|
||||
OpenDingTalkID: "open-test-user",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "user and group mixed",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventInChat},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
wantErr: "cannot be consumed in one command",
|
||||
},
|
||||
{
|
||||
name: "duplicate keys collapse to one",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventMention},
|
||||
},
|
||||
wantErr: "multiple event keys are required",
|
||||
},
|
||||
{
|
||||
name: "unknown event",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, "user_im_unknown"},
|
||||
},
|
||||
wantErr: "unknown personal event key",
|
||||
},
|
||||
{
|
||||
name: "missing user target",
|
||||
opts: personalConsumeOptions{EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O}},
|
||||
wantErr: "one of --user or --open-dingtalk-id",
|
||||
},
|
||||
{
|
||||
name: "missing group target",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
||||
},
|
||||
wantErr: "--group is required",
|
||||
},
|
||||
{
|
||||
name: "user identity flags conflict",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
OpenDingTalkID: "open-test-user",
|
||||
},
|
||||
wantErr: "mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "group target on user events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
wantErr: "--group cannot be used",
|
||||
},
|
||||
{
|
||||
name: "user target on group events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventInChat, personal.EventGroupUpdated},
|
||||
UserID: "test-user-001",
|
||||
GroupID: "cid-test",
|
||||
},
|
||||
wantErr: "cannot be used with group-scoped events",
|
||||
},
|
||||
{
|
||||
name: "target on no target events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
UserID: "test-user-001",
|
||||
},
|
||||
wantErr: "do not use --user",
|
||||
},
|
||||
{
|
||||
name: "filter message events",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllGroupChat},
|
||||
QueryCSV: "alarm",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "filter mixed with action",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventSingleChat, personal.EventReadO2O},
|
||||
UserID: "test-user-001",
|
||||
QueryCSV: "alarm",
|
||||
},
|
||||
wantErr: "require all selected events to be message receive events",
|
||||
},
|
||||
{
|
||||
name: "invalid message filter",
|
||||
opts: personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
FilterJSON: "{",
|
||||
},
|
||||
wantErr: "filter",
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
plans, err := preparePersonalMultiOptions(test.opts)
|
||||
if test.wantErr != "" {
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("preparePersonalMultiOptions() error = %v", err)
|
||||
}
|
||||
if len(plans) != len(test.opts.EventKeys) {
|
||||
t.Fatalf("plans = %d, want %d", len(plans), len(test.opts.EventKeys))
|
||||
}
|
||||
for _, plan := range plans {
|
||||
def, _ := personal.Lookup(plan.EventKey)
|
||||
if def.RuleType == "at" || def.RuleType == "all" {
|
||||
if plan.UserID != "" || plan.OpenDingTalkID != "" || plan.GroupID != "" {
|
||||
t.Fatalf("no-target plan retained target: %#v", plan)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsRejectsNonPublicEvent(t *testing.T) {
|
||||
oldLookup := personalLookupDefinition
|
||||
t.Cleanup(func() { personalLookupDefinition = oldLookup })
|
||||
personalLookupDefinition = func(eventKey string) (personal.Definition, bool) {
|
||||
def, ok := personal.Lookup(eventKey)
|
||||
if eventKey == personal.EventAllSingleChat {
|
||||
def.Public = false
|
||||
}
|
||||
return def, ok
|
||||
}
|
||||
|
||||
_, err := preparePersonalMultiOptions(personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "not publicly available") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDedupePersonalEventKeysSkipsEmptyValues(t *testing.T) {
|
||||
got := dedupePersonalEventKeys([]string{"", " event-a ", "event-a", "event-b"})
|
||||
if !reflect.DeepEqual(got, []string{"event-a", "event-b"}) {
|
||||
t.Fatalf("deduped keys = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreparePersonalMultiOptionsRejectsSingleOnlyFlags(t *testing.T) {
|
||||
base := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
||||
tests := []struct {
|
||||
name string
|
||||
set func(*personalConsumeOptions)
|
||||
}{
|
||||
{name: "subscribe-id", set: func(o *personalConsumeOptions) { o.SubscribeID = "sub" }},
|
||||
{name: "rule", set: func(o *personalConsumeOptions) { o.Rule = "all" }},
|
||||
{name: "event-types", set: func(o *personalConsumeOptions) { o.Common.EventTypes = []string{"x"} }},
|
||||
{name: "filter", set: func(o *personalConsumeOptions) { o.Common.Filter = "x" }},
|
||||
{name: "foreground", set: func(o *personalConsumeOptions) { o.Common.Foreground = true }},
|
||||
{name: "force", set: func(o *personalConsumeOptions) { o.Common.Force = true }},
|
||||
{name: "debug-raw-events", set: func(o *personalConsumeOptions) { o.DebugRawEvents = true }},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
opts := base
|
||||
test.set(&opts)
|
||||
if _, err := preparePersonalMultiOptions(opts); err == nil {
|
||||
t.Fatal("option succeeded")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageEventConsumeMultiRejectsExplicitSingleOnlyFlagsEvenWhenEmpty(t *testing.T) {
|
||||
oldRun := eventRunPersonalConsume
|
||||
defer func() { eventRunPersonalConsume = oldRun }()
|
||||
eventRunPersonalConsume = func(*cobra.Command, personalConsumeOptions) error {
|
||||
t.Fatal("personal consume ran after explicit multi-event flag")
|
||||
return nil
|
||||
}
|
||||
|
||||
flags := []string{
|
||||
"--subscribe-id=",
|
||||
"--rule=",
|
||||
"--event-types=",
|
||||
"--filter=",
|
||||
"--foreground=false",
|
||||
"--force=false",
|
||||
"--debug-raw-events=false",
|
||||
}
|
||||
for _, flag := range flags {
|
||||
t.Run(flag, func(t *testing.T) {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SetOut(io.Discard)
|
||||
cmd.SetErr(io.Discard)
|
||||
cmd.SetArgs([]string{personal.EventMention, personal.EventAllSingleChat, flag})
|
||||
err := cmd.Execute()
|
||||
if err == nil || !strings.Contains(err.Error(), "not supported when consuming multiple events") {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyCreatesAndCleansAllSubscriptions(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
identity := personal.Identity{AccessToken: "token", CorpID: "corp", UserID: "user", ClientID: "client", SourceID: "open"}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) { return identity, nil }
|
||||
createdKeys := make([]string, 0, 2)
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
createdKeys = append(createdKeys, opts.EventKey)
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
||||
}
|
||||
var states []personal.RunState
|
||||
personalUpsertRunState = func(_ string, state personal.RunState) error {
|
||||
states = append(states, state)
|
||||
return nil
|
||||
}
|
||||
var deleted []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(_ context.Context, cfg consume.Config, specs []consume.ConsumerSpec) error {
|
||||
if !cfg.Flatten || cfg.Projector == nil || len(specs) != 2 {
|
||||
t.Fatalf("consume config/specs = %#v / %#v", cfg, specs)
|
||||
}
|
||||
for i, spec := range specs {
|
||||
if spec.EventKey != createdKeys[i] || spec.SubscribeID != "sub-"+createdKeys[i] || !reflect.DeepEqual(spec.EventTypes, []string{createdKeys[i]}) {
|
||||
t.Fatalf("spec[%d] = %#v", i, spec)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
Flatten: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("runPersonalEventConsume() error = %v", err)
|
||||
}
|
||||
if len(states) != 2 || len(deleted) != 2 || len(removed) != 2 {
|
||||
t.Fatalf("states=%#v deleted=%#v removed=%#v", states, deleted, removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyRollsBackPartialCreation(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
wantErr := errors.New("second subscription failed")
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", wantErr
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
var deleted []string
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, id string) error {
|
||||
deleted = append(deleted, id)
|
||||
return nil
|
||||
}
|
||||
var removed []string
|
||||
personalRemoveRunStates = func(_ string, ids []string) error {
|
||||
removed = append(removed, ids...)
|
||||
return nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("RunMany called after partial creation failure")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(deleted, []string{"sub-first"}) || !reflect.DeepEqual(removed, []string{"sub-first"}) {
|
||||
t.Fatalf("rollback deleted=%#v removed=%#v", deleted, removed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyPersistsFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", errors.New("second subscription failed")
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("partial creation unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeSinglePersistsLocalFailureBeforeRollback(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-one"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error {
|
||||
return errors.New("state disk failed")
|
||||
}
|
||||
personalDeleteSubscription = func(_ *personal.Client, _ context.Context, _ string) error {
|
||||
order = append(order, "delete")
|
||||
return nil
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKey: personal.EventMention,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("run-state failure unexpectedly succeeded")
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"complete_failure", "delete"}) {
|
||||
t.Fatalf("failure/rollback order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyCancellationReleasesBeforeCanceledCleanup(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
|
||||
var order []string
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return &personalOrderingAttemptStore{order: &order}
|
||||
}
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{
|
||||
AccessToken: "token",
|
||||
ClientID: "client",
|
||||
SourceID: "open",
|
||||
LocalSubject: "subject",
|
||||
}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(
|
||||
_ context.Context,
|
||||
_ *personal.Client,
|
||||
_ personal.Identity,
|
||||
opts personalConsumeOptions,
|
||||
) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
if calls == 2 {
|
||||
return nil, "", "", context.Canceled
|
||||
}
|
||||
return &personal.Subscription{SubscribeID: "sub-first"}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalDeleteSubscription = func(_ *personal.Client, cleanupCtx context.Context, _ string) error {
|
||||
if cleanupCtx.Err() == nil {
|
||||
t.Fatal("cancellation cleanup received a live context")
|
||||
}
|
||||
order = append(order, "delete")
|
||||
return cleanupCtx.Err()
|
||||
}
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
cmd.SetContext(ctx)
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if !errors.Is(err, context.Canceled) {
|
||||
t.Fatalf("cancellation error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(order, []string{"release", "delete"}) {
|
||||
t.Fatalf("release/canceled-cleanup order = %#v", order)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManyRejectsInvalidSubscriptionResults(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
ensure func(int, personalConsumeOptions) *personal.Subscription
|
||||
upsertErr error
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "nil subscription",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription { return nil },
|
||||
wantErr: "empty subscription",
|
||||
},
|
||||
{
|
||||
name: "empty subscribe id",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription { return &personal.Subscription{} },
|
||||
wantErr: "empty subscribe_id",
|
||||
},
|
||||
{
|
||||
name: "duplicate subscribe id",
|
||||
ensure: func(int, personalConsumeOptions) *personal.Subscription {
|
||||
return &personal.Subscription{SubscribeID: "sub-duplicate"}
|
||||
},
|
||||
wantErr: "duplicate subscribe_id",
|
||||
},
|
||||
{
|
||||
name: "run state write failure",
|
||||
ensure: func(_ int, opts personalConsumeOptions) *personal.Subscription {
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}
|
||||
},
|
||||
upsertErr: errors.New("state write failed"),
|
||||
wantErr: "save run state",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
calls := 0
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
calls++
|
||||
return test.ensure(calls, opts), opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return test.upsertErr }
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return nil }
|
||||
personalRemoveRunStates = func(string, []string) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error {
|
||||
t.Fatal("RunMany called with invalid subscription result")
|
||||
return nil
|
||||
}
|
||||
|
||||
err := runPersonalEventConsume(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), test.wantErr) {
|
||||
t.Fatalf("error = %v, want %q", err, test.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunPersonalEventConsumeManyDryRunDoesNotCreateSubscriptions(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
t.Setenv("DWS_CONFIG_DIR", t.TempDir())
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(context.Context, *personal.Client, personal.Identity, personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
t.Fatal("dry-run created a subscription")
|
||||
return nil, "", "", nil
|
||||
}
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
err := runPersonalEventConsume(cmd, personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention, personal.EventAllSingleChat},
|
||||
QueryCSV: "alarm",
|
||||
Common: commonConsumeOptions{DryRun: true},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("dry-run error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "subscription[0]") ||
|
||||
!strings.Contains(stderr.String(), "subscription[1]") ||
|
||||
!strings.Contains(stderr.String(), "filter=") {
|
||||
t.Fatalf("dry-run subscriptions missing:\n%s", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRunPersonalEventConsumeManySetupAndCleanupEdges(t *testing.T) {
|
||||
valid := personalConsumeOptions{EventKeys: []string{personal.EventMention, personal.EventAllSingleChat}}
|
||||
|
||||
t.Run("prepare error", func(t *testing.T) {
|
||||
err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), personalConsumeOptions{
|
||||
EventKeys: []string{personal.EventMention},
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "multiple event keys") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("format warning and identity error", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := cmd.Flags().Set("format", "bogus"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantErr := errors.New("identity")
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{}, wantErr
|
||||
}
|
||||
opts := valid
|
||||
opts.Common.FormatRaw = "bogus"
|
||||
if err := runPersonalEventConsumeMany(cmd, opts); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "using ndjson") {
|
||||
t.Fatalf("warning = %q", stderr.String())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("flatten raw conflict", func(t *testing.T) {
|
||||
cmd := newPersonalCoverageCommand()
|
||||
if err := cmd.Flags().Set("format", "raw"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
opts := valid
|
||||
opts.Flatten = true
|
||||
opts.Common.FormatRaw = "raw"
|
||||
if err := runPersonalEventConsumeMany(cmd, opts); err == nil || !strings.Contains(err.Error(), "mutually exclusive") {
|
||||
t.Fatalf("error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("route validation and output conflict", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
|
||||
opts := valid
|
||||
opts.Common.RoutesRaw = []string{"bad-route"}
|
||||
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), opts); err == nil {
|
||||
t.Fatal("invalid route succeeded")
|
||||
}
|
||||
|
||||
wantErr := errors.New("validate")
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return wantErr }
|
||||
if err := runPersonalEventConsumeMany(newPersonalCoverageCommand(), valid); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("config validation error = %v", err)
|
||||
}
|
||||
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
personalValidateNoOutputConflict = func(consume.Config, string) error { return wantErr }
|
||||
cmd := newPersonalCoverageCommand()
|
||||
if err := cmd.Flags().Set("output", "events.json"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("output conflict error = %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("runtime error reports cleanup failures", func(t *testing.T) {
|
||||
restore := installPersonalManySeams(t)
|
||||
defer restore()
|
||||
personalResolveEventIdentity = func(context.Context, string, string) (personal.Identity, error) {
|
||||
return personal.Identity{AccessToken: "token", ClientID: "client", SourceID: "open", LocalSubject: "subject"}, nil
|
||||
}
|
||||
personalEnsureSubscription = func(_ context.Context, _ *personal.Client, _ personal.Identity, opts personalConsumeOptions) (*personal.Subscription, string, string, error) {
|
||||
return &personal.Subscription{SubscribeID: "sub-" + opts.EventKey}, opts.EventKey, "all", nil
|
||||
}
|
||||
personalUpsertRunState = func(string, personal.RunState) error { return nil }
|
||||
personalValidateConsumeConfig = func(consume.Config) error { return nil }
|
||||
cleanupErr := errors.New("cleanup")
|
||||
personalDeleteSubscription = func(*personal.Client, context.Context, string) error { return cleanupErr }
|
||||
personalRemoveRunStates = func(string, []string) error { return cleanupErr }
|
||||
runErr := errors.New("run many")
|
||||
personalConsumeRunMany = func(context.Context, consume.Config, []consume.ConsumerSpec) error { return runErr }
|
||||
|
||||
cmd := newPersonalCoverageCommand()
|
||||
var stderr bytes.Buffer
|
||||
cmd.SetErr(&stderr)
|
||||
if err := runPersonalEventConsumeMany(cmd, valid); !errors.Is(err, runErr) {
|
||||
t.Fatalf("runtime error = %v", err)
|
||||
}
|
||||
if !strings.Contains(stderr.String(), "failed to clean personal subscription") ||
|
||||
!strings.Contains(stderr.String(), "failed to clean personal event run state") {
|
||||
t.Fatalf("cleanup warnings = %q", stderr.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestStopPersonalConsumersUsesTargetedRPCAndLegacyFallback(t *testing.T) {
|
||||
oldStop := personalStopConsumers
|
||||
oldQuery := personalQueryStatus
|
||||
oldFind := personalFindProcess
|
||||
oldSignal := personalSignalProcess
|
||||
defer func() {
|
||||
personalStopConsumers = oldStop
|
||||
personalQueryStatus = oldQuery
|
||||
personalFindProcess = oldFind
|
||||
personalSignalProcess = oldSignal
|
||||
}()
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{Stopped: []string{"sub-a"}}, nil
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy status queried after targeted stop succeeded")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{}, busctl.ErrConsumerStopUnsupported
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
return &transport.StatusResp{Consumers: []transport.StatusConsumer{{PID: 321, SubscribeID: "sub-a"}}}, nil
|
||||
}
|
||||
proc := &os.Process{}
|
||||
personalFindProcess = func(int) (*os.Process, error) { return proc, nil }
|
||||
signals := 0
|
||||
personalSignalProcess = func(*os.Process, os.Signal) error { signals++; return nil }
|
||||
var warning bytes.Buffer
|
||||
if err := stopPersonalConsumers(&warning, "endpoint", []string{"sub-a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if signals != 1 || !strings.Contains(warning.String(), "falling back to process signal") {
|
||||
t.Fatalf("signals=%d warning=%q", signals, warning.String())
|
||||
}
|
||||
|
||||
wantErr := errors.New("targeted stop transport failed")
|
||||
personalStopConsumers = func(string, []string) (transport.ConsumerStopResp, error) {
|
||||
return transport.ConsumerStopResp{}, wantErr
|
||||
}
|
||||
personalQueryStatus = func(string) (*transport.StatusResp, error) {
|
||||
t.Fatal("legacy fallback ran for a non-compatibility error")
|
||||
return nil, nil
|
||||
}
|
||||
if err := stopPersonalConsumers(io.Discard, "endpoint", []string{"sub-a"}); !errors.Is(err, wantErr) {
|
||||
t.Fatalf("transport error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func installPersonalManySeams(t *testing.T) func() {
|
||||
t.Helper()
|
||||
oldIdentity := personalResolveEventIdentity
|
||||
oldLookup := personalLookupDefinition
|
||||
oldEnsure := personalEnsureSubscription
|
||||
oldAttemptStore := personalNewSubscriptionAttemptStore
|
||||
oldUpsert := personalUpsertRunState
|
||||
oldDelete := personalDeleteSubscription
|
||||
oldRemove := personalRemoveRunStates
|
||||
oldRunMany := personalConsumeRunMany
|
||||
oldValidate := personalValidateConsumeConfig
|
||||
oldConflict := personalValidateNoOutputConflict
|
||||
personalNewSubscriptionAttemptStore = func(string) personalSubscriptionAttemptStore {
|
||||
return personalNoopAttemptStore{}
|
||||
}
|
||||
return func() {
|
||||
personalResolveEventIdentity = oldIdentity
|
||||
personalLookupDefinition = oldLookup
|
||||
personalEnsureSubscription = oldEnsure
|
||||
personalNewSubscriptionAttemptStore = oldAttemptStore
|
||||
personalUpsertRunState = oldUpsert
|
||||
personalDeleteSubscription = oldDelete
|
||||
personalRemoveRunStates = oldRemove
|
||||
personalConsumeRunMany = oldRunMany
|
||||
personalValidateConsumeConfig = oldValidate
|
||||
personalValidateNoOutputConflict = oldConflict
|
||||
}
|
||||
}
|
||||
|
||||
type personalNoopAttemptStore struct{}
|
||||
|
||||
func (personalNoopAttemptStore) Claim(specs []personal.AttemptSpec, _ time.Duration) (*personal.AttemptClaim, error) {
|
||||
fingerprints := make([]string, 0, len(specs))
|
||||
for _, spec := range specs {
|
||||
fingerprints = append(fingerprints, spec.Fingerprint)
|
||||
}
|
||||
return &personal.AttemptClaim{
|
||||
AttemptID: "test-attempt",
|
||||
Fingerprints: fingerprints,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (personalNoopAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
type personalOrderingAttemptStore struct {
|
||||
order *[]string
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Claim(
|
||||
specs []personal.AttemptSpec,
|
||||
lease time.Duration,
|
||||
) (*personal.AttemptClaim, error) {
|
||||
return personalNoopAttemptStore{}.Claim(specs, lease)
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteSuccess(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "complete_success")
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) CompleteFailure(
|
||||
_ *personal.AttemptClaim,
|
||||
_ []string,
|
||||
failure personal.AttemptFailure,
|
||||
) (personal.AttemptHold, error) {
|
||||
*s.order = append(*s.order, "complete_failure")
|
||||
return personal.AttemptHold{
|
||||
Fingerprint: failure.Fingerprint,
|
||||
Retryability: failure.Retryability,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *personalOrderingAttemptStore) Release(*personal.AttemptClaim) error {
|
||||
*s.order = append(*s.order, "release")
|
||||
return nil
|
||||
}
|
||||
@@ -47,12 +47,18 @@ func TestPersonalEventListHidesSchemaIDs(t *testing.T) {
|
||||
assertPersonalOutputHidesSchemaIDs(t, got)
|
||||
for _, eventKey := range []string{
|
||||
personal.EventFromUser,
|
||||
personal.EventAllSingleChat,
|
||||
personal.EventAllGroupChat,
|
||||
personal.EventReadO2O,
|
||||
personal.EventReadGroup,
|
||||
personal.EventRecallO2O,
|
||||
personal.EventRecallGroup,
|
||||
personal.EventReactionO2O,
|
||||
personal.EventReactionGroup,
|
||||
personal.EventGroupUpdated,
|
||||
personal.EventGroupMemberAdded,
|
||||
personal.EventGroupMemberExited,
|
||||
personal.EventGroupDisbanded,
|
||||
} {
|
||||
if !strings.Contains(got, eventKey) {
|
||||
t.Fatalf("list output missing %s: %s", eventKey, got)
|
||||
@@ -230,6 +236,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
personal.EventMention,
|
||||
personal.EventSingleChat,
|
||||
personal.EventInChat,
|
||||
personal.EventAllSingleChat,
|
||||
personal.EventAllGroupChat,
|
||||
} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
@@ -265,6 +273,8 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
"message_id",
|
||||
"create_time",
|
||||
"event_time",
|
||||
"quoted_message",
|
||||
"forward_messages",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("schema output for %s missing %q: %s", eventKey, want, got)
|
||||
@@ -309,6 +319,103 @@ func TestPersonalEventFlattenedSchemaUsesSingleJSONSchema(t *testing.T) {
|
||||
if _, ok := props["content"].(map[string]any); !ok {
|
||||
t.Fatalf("schema.properties.content = %#v, want object", props["content"])
|
||||
}
|
||||
quoted, ok := props["quoted_message"].(map[string]any)
|
||||
if !ok || quoted["type"] != "object" {
|
||||
t.Fatalf("schema.properties.quoted_message = %#v, want object", props["quoted_message"])
|
||||
}
|
||||
forward, ok := props["forward_messages"].(map[string]any)
|
||||
if !ok || forward["type"] != "array" {
|
||||
t.Fatalf("schema.properties.forward_messages = %#v, want array", props["forward_messages"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalGroupLifecycleEventSchemaUsesConservativePayload(t *testing.T) {
|
||||
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupDisbanded} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema metadata = %#v", doc)
|
||||
}
|
||||
required, ok := doc["required_params"].([]any)
|
||||
if !ok || len(required) != 1 || required[0] != "group" {
|
||||
t.Fatalf("required_params = %#v, want [group]", doc["required_params"])
|
||||
}
|
||||
schema := doc["schema"].(map[string]any)
|
||||
properties := schema["properties"].(map[string]any)
|
||||
if len(properties) != 5 {
|
||||
t.Fatalf("schema.properties = %#v, want five conservative fields", properties)
|
||||
}
|
||||
payload, ok := properties["payload"].(map[string]any)
|
||||
if !ok || payload["type"] != "object" || payload["additionalProperties"] != true {
|
||||
t.Fatalf("schema.properties.payload = %#v", properties["payload"])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalGroupMemberEventSchemaMatchesFlatOutput(t *testing.T) {
|
||||
wantProperties := []string{
|
||||
"type", "event_id", "timestamp", "subscribe_id", "conversation_id",
|
||||
"operator", "operator_open_dingtalk_id", "members", "event_time",
|
||||
}
|
||||
for _, eventKey := range []string{personal.EventGroupMemberAdded, personal.EventGroupMemberExited} {
|
||||
t.Run(eventKey, func(t *testing.T) {
|
||||
cmd := newEventSchemaCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
cmd.SetArgs([]string{eventKey, "--flatten"})
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal(out.Bytes(), &doc); err != nil {
|
||||
t.Fatalf("schema output is not JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if doc["event_key"] != eventKey || doc["rule_type"] != "group" || doc["jq_root_path"] != "." {
|
||||
t.Fatalf("schema metadata = %#v", doc)
|
||||
}
|
||||
properties := doc["schema"].(map[string]any)["properties"].(map[string]any)
|
||||
if len(properties) != len(wantProperties) {
|
||||
t.Fatalf("schema.properties = %#v, want exactly %d flat fields", properties, len(wantProperties))
|
||||
}
|
||||
for _, field := range wantProperties {
|
||||
if _, ok := properties[field].(map[string]any); !ok {
|
||||
t.Fatalf("schema missing %q: %#v", field, properties)
|
||||
}
|
||||
}
|
||||
members := properties["members"].(map[string]any)
|
||||
items, ok := members["items"].(map[string]any)
|
||||
if !ok || members["type"] != "array" || items["type"] != "object" {
|
||||
t.Fatalf("members schema = %#v", members)
|
||||
}
|
||||
memberProperties, ok := items["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("members.items.properties = %#v", items["properties"])
|
||||
}
|
||||
for _, field := range []string{"nick", "open_dingtalk_id"} {
|
||||
if _, ok := memberProperties[field].(map[string]any); !ok {
|
||||
t.Fatalf("member schema missing %q: %#v", field, memberProperties)
|
||||
}
|
||||
}
|
||||
if _, ok := properties["payload"]; ok {
|
||||
t.Fatalf("group member schema exposed generic payload: %#v", properties)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -485,6 +592,64 @@ func TestPersonalEventFromUserIsPubliclyAvailable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalNewIMEventsDryRunAndValidation(t *testing.T) {
|
||||
configDir := setupPersonalIdentityToken(t, &authpkg.TokenData{
|
||||
AccessToken: "access-1",
|
||||
RefreshToken: "refresh-1",
|
||||
ExpiresAt: time.Now().Add(time.Hour),
|
||||
RefreshExpAt: time.Now().Add(24 * time.Hour),
|
||||
CorpID: "corp-1",
|
||||
UserID: "user-1",
|
||||
ClientID: "client-1",
|
||||
})
|
||||
t.Setenv("DWS_CONFIG_DIR", configDir)
|
||||
|
||||
for _, test := range []struct {
|
||||
eventKey string
|
||||
args []string
|
||||
}{
|
||||
{eventKey: personal.EventAllSingleChat},
|
||||
{eventKey: personal.EventAllGroupChat},
|
||||
{eventKey: personal.EventGroupUpdated, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupMemberAdded, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupMemberExited, args: []string{"--group", "cid-test-group"}},
|
||||
{eventKey: personal.EventGroupDisbanded, args: []string{"--group", "cid-test-group"}},
|
||||
} {
|
||||
t.Run(test.eventKey, func(t *testing.T) {
|
||||
if err := ensurePublicPersonalEvent(test.eventKey); err != nil {
|
||||
t.Fatalf("ensurePublicPersonalEvent() error = %v", err)
|
||||
}
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs(append([]string{test.eventKey}, append(test.args, "--dry-run")...))
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("dry-run Execute() error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, eventKey := range []string{personal.EventAllSingleChat, personal.EventAllGroupChat} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{eventKey, "--user", "test-user-001", "--dry-run"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--user is not supported for "+eventKey) {
|
||||
t.Fatalf("%s scoped user error = %v", eventKey, err)
|
||||
}
|
||||
}
|
||||
|
||||
for _, eventKey := range []string{personal.EventGroupUpdated, personal.EventGroupMemberAdded, personal.EventGroupMemberExited, personal.EventGroupDisbanded} {
|
||||
cmd := newEventConsumeCommand()
|
||||
cmd.SilenceUsage = true
|
||||
cmd.SilenceErrors = true
|
||||
cmd.SetArgs([]string{eventKey, "--dry-run"})
|
||||
if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "--group is required for "+eventKey) {
|
||||
t.Fatalf("%s missing group error = %v", eventKey, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
root.SilenceUsage = true
|
||||
@@ -550,6 +715,14 @@ func TestEventConsumeCobraSchemaIncludesOpenDingTalkID(t *testing.T) {
|
||||
t.Fatalf("schema constraint %s = %#v, missing %#v", field, groups, want)
|
||||
}
|
||||
assertJSONConstraintGroup("require_one_of", []string{"event_key", "subscribe-id"})
|
||||
positionals, ok := doc["positionals"].([]any)
|
||||
if !ok || len(positionals) != 1 {
|
||||
t.Fatalf("schema positionals = %#v", doc["positionals"])
|
||||
}
|
||||
eventKey, ok := positionals[0].(map[string]any)
|
||||
if !ok || eventKey["name"] != "event_key" || eventKey["variadic"] != true {
|
||||
t.Fatalf("event_key positional = %#v, want variadic", positionals[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalEventSchemaRejectsTableFormat(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
)
|
||||
|
||||
func TestPersonalBusProfileSelectorUsesDefaultBlankCurrentBeforeRuntimeEnrichedIdentity(t *testing.T) {
|
||||
configDir, cfg, blankSelector, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identityAfterRuntimeEnrichment := personal.Identity{
|
||||
CorpID: cfg.Profiles[0].CorpID,
|
||||
UserID: cfg.Profiles[1].UserID,
|
||||
}
|
||||
if got := personalBusProfileSelector(configDir, identityAfterRuntimeEnrichment); got != blankSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want default blank selector %q", got, blankSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBusProfileSelectorUsesDefaultExactCurrent(t *testing.T) {
|
||||
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = exactSelector
|
||||
cfg.OrgCurrentProfiles = map[string]string{cfg.Profiles[0].CorpID: exactSelector}
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want default exact selector %q", got, exactSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalBusProfileSelectorPrefersExplicitRuntimeSelector(t *testing.T) {
|
||||
configDir, cfg, blankSelector, _ := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.CurrentProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
|
||||
const explicitSelector = "corp_explicit:user_explicit"
|
||||
authpkg.SetRuntimeProfile(explicitSelector)
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != explicitSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want explicit selector %q", got, explicitSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusProfileSelectorFallsBackToMatchingIdentity(t *testing.T) {
|
||||
configDir, cfg, _, exactSelector := seedPersonalBusProfileSelectorConfig(t)
|
||||
cfg.Profiles = append(cfg.Profiles, authpkg.Profile{
|
||||
Name: "Other Current",
|
||||
CorpID: "corp_event_other_fixture",
|
||||
CorpName: "Other Fixture Organization",
|
||||
UserID: "identity_event_other_fixture",
|
||||
})
|
||||
cfg.CurrentProfile = authpkg.ProfileSelectionSelector(cfg.Profiles[2], cfg)
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile("")
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
|
||||
identity := personal.Identity{CorpID: cfg.Profiles[1].CorpID, UserID: cfg.Profiles[1].UserID}
|
||||
if got := personalBusProfileSelector(configDir, identity); got != exactSelector {
|
||||
t.Fatalf("personalBusProfileSelector() = %q, want identity fallback %q", got, exactSelector)
|
||||
}
|
||||
}
|
||||
|
||||
func seedPersonalBusProfileSelectorConfig(t *testing.T) (string, *authpkg.ProfilesConfig, string, string) {
|
||||
t.Helper()
|
||||
configDir := t.TempDir()
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "External Fixture",
|
||||
CorpID: "corp_event_current_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
},
|
||||
{
|
||||
Name: "Exact Fixture",
|
||||
CorpID: "corp_event_current_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_runtime_enriched_fixture",
|
||||
},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
exactSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[1], cfg)
|
||||
if blankSelector == "" || blankSelector == cfg.Profiles[0].CorpID {
|
||||
t.Fatalf("blank selector = %q, want stable account selector", blankSelector)
|
||||
}
|
||||
return configDir, cfg, blankSelector, exactSelector
|
||||
}
|
||||
@@ -10,17 +10,21 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
event := newEventCommand()
|
||||
markdown := &cobra.Command{Use: "markdown"}
|
||||
unregistered := &cobra.Command{Use: "unregistered", Run: func(*cobra.Command, []string) {}}
|
||||
root.AddCommand(event, unregistered)
|
||||
root.AddCommand(event, markdown, unregistered)
|
||||
|
||||
hideNonDirectRuntimeCommands(root)
|
||||
|
||||
if event.Hidden {
|
||||
t.Fatal("built-in event command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if markdown.Hidden {
|
||||
t.Fatal("locally routed markdown command was hidden by the direct-runtime visibility filter")
|
||||
}
|
||||
if !unregistered.Hidden {
|
||||
t.Fatal("control command outside the built-in/direct-runtime sets remained visible")
|
||||
}
|
||||
@@ -33,7 +37,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
leaves = append(leaves, command.Name())
|
||||
}
|
||||
sort.Strings(leaves)
|
||||
want := []string{"consume", "list", "schema", "status", "stop"}
|
||||
want := []string{"+listen-im", "consume", "list", "schema", "status", "stop"}
|
||||
if len(leaves) != len(want) {
|
||||
t.Fatalf("public event leaves = %v, want %v", leaves, want)
|
||||
}
|
||||
@@ -44,7 +48,7 @@ func TestEventCommandRemainsVisibleAsBuiltInPublicGroup(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
func TestCrossPlatformCoveragePluginCannotReplaceBuiltInEventCommand(t *testing.T) {
|
||||
root := &cobra.Command{Use: "dws"}
|
||||
builtIn := newEventCommand()
|
||||
root.AddCommand(builtIn)
|
||||
|
||||
@@ -17,7 +17,9 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
authpkg "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/event/personal"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/keychain"
|
||||
)
|
||||
|
||||
// A bounded run never arms the stdin-EOF watcher, regardless of stdin
|
||||
@@ -63,3 +65,100 @@ func TestPersonalBusSpawnArgs_ForwardsProfile(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoveragePersonalBusSpawnArgsPreservesReservedBlankProfile(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
t.Setenv(keychain.DisableKeychainEnv, "1")
|
||||
t.Setenv(keychain.StorageDirEnv, t.TempDir())
|
||||
cfg := &authpkg.ProfilesConfig{
|
||||
Version: 2,
|
||||
OrgCurrentProfiles: map[string]string{
|
||||
"corp_event_fixture": "corp_event_fixture:identity_exact_fixture",
|
||||
},
|
||||
Profiles: []authpkg.Profile{
|
||||
{
|
||||
Name: "Fixture Organization",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
},
|
||||
{
|
||||
Name: "Exact Fixture Account",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_exact_fixture",
|
||||
},
|
||||
},
|
||||
}
|
||||
blankSelector := authpkg.ProfileSelectionSelector(cfg.Profiles[0], cfg)
|
||||
cfg.PrimaryProfile = blankSelector
|
||||
cfg.CurrentProfile = blankSelector
|
||||
if err := authpkg.SaveProfiles(configDir, cfg); err != nil {
|
||||
t.Fatalf("SaveProfiles() error = %v", err)
|
||||
}
|
||||
blankToken := &authpkg.TokenData{
|
||||
AccessToken: "parent-blank-token",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
}
|
||||
exactToken := &authpkg.TokenData{
|
||||
AccessToken: "other-exact-token",
|
||||
CorpID: "corp_event_fixture",
|
||||
CorpName: "Fixture Organization",
|
||||
UserID: "identity_exact_fixture",
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForCorpID(blankToken.CorpID, blankToken); err != nil {
|
||||
t.Fatalf("save parent blank token: %v", err)
|
||||
}
|
||||
if err := authpkg.SaveTokenDataKeychainForIdentity(exactToken.CorpID, exactToken.UserID, exactToken); err != nil {
|
||||
t.Fatalf("save other exact token: %v", err)
|
||||
}
|
||||
|
||||
// Runtime identity enrichment points at the exact sibling, but the parent
|
||||
// already loaded the persisted blank current profile.
|
||||
identity := personal.Identity{
|
||||
CorpID: "corp_event_fixture",
|
||||
UserID: "identity_exact_fixture",
|
||||
SourceID: "open",
|
||||
}
|
||||
selector := personalBusProfileSelector(configDir, identity)
|
||||
want := blankSelector
|
||||
if selector != want || selector == identity.CorpID {
|
||||
t.Fatalf("personalBusProfileSelector(blank) = %q, want reserved %q", selector, want)
|
||||
}
|
||||
args := personalBusSpawnArgs(identity, "", "", selector)
|
||||
forwardedSelector := ""
|
||||
for i := 0; i+1 < len(args); i++ {
|
||||
if args[i] == "--profile" && args[i+1] == want {
|
||||
forwardedSelector = args[i+1]
|
||||
break
|
||||
}
|
||||
}
|
||||
if forwardedSelector == "" {
|
||||
t.Fatalf("spawn args did not preserve reserved blank selector: %v", args)
|
||||
}
|
||||
parentToken, err := authpkg.LoadTokenDataForProfile(configDir, selector)
|
||||
if err != nil {
|
||||
t.Fatalf("load parent token: %v", err)
|
||||
}
|
||||
authpkg.SetRuntimeProfile(forwardedSelector)
|
||||
t.Cleanup(func() { authpkg.SetRuntimeProfile("") })
|
||||
childToken, err := authpkg.LoadTokenData(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("load detached child token: %v", err)
|
||||
}
|
||||
if parentToken.AccessToken != blankToken.AccessToken ||
|
||||
childToken.AccessToken != parentToken.AccessToken ||
|
||||
childToken.UserID != "" {
|
||||
t.Fatalf("parent/child token drift: parent=%#v child=%#v", parentToken, childToken)
|
||||
}
|
||||
|
||||
authpkg.SetRuntimeProfile(want)
|
||||
inferredExact := personal.Identity{
|
||||
CorpID: "corp_event_fixture",
|
||||
UserID: "identity_exact_fixture",
|
||||
SourceID: "open",
|
||||
}
|
||||
if got := personalBusProfileSelector(configDir, inferredExact); got != want {
|
||||
t.Fatalf("runtime blank selector changed after inferred userId: got %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestEventStopDryRunPrecedesConfirmationAndReturnsPreview(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
func TestCrossPlatformCoverageEventStopDryRunDoesNotBypassTargetValidation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
|
||||
@@ -103,3 +103,65 @@ func TestFlagErrorWithSuggestions_fallbackTailHint(t *testing.T) {
|
||||
t.Fatalf("err tail = %q, want suffix See 'send --help' for usage.", msg)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlagErrorWithSuggestionsReviewedProtectionRoutes(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
for _, tc := range []struct {
|
||||
path []string
|
||||
flag string
|
||||
wantReason string
|
||||
wantHint string
|
||||
}{
|
||||
{path: []string{"chat", "message", "list-by-sender"}, flag: "time", wantReason: "blocked_flag", wantHint: "blocked"},
|
||||
{path: []string{"drive", "list"}, flag: "space", wantReason: "ambiguous_flag", wantHint: "ambiguous"},
|
||||
} {
|
||||
t.Run(strings.Join(tc.path, "/"), func(t *testing.T) {
|
||||
cmd := mustFindCommand(t, root, tc.path...)
|
||||
err := flagErrorWithSuggestions(cmd, fmt.Errorf("unknown flag: --%s", tc.flag))
|
||||
var ae *apperrors.Error
|
||||
if !stderrors.As(err, &ae) {
|
||||
t.Fatalf("want *apperrors.Error, got %T", err)
|
||||
}
|
||||
if ae.Reason != tc.wantReason || !strings.Contains(ae.Hint, tc.wantHint) || !strings.Contains(ae.Hint, "--help") {
|
||||
t.Fatalf("protected error = reason %q hint %q", ae.Reason, ae.Hint)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedFlagProtectionAndInstallerEdges(t *testing.T) {
|
||||
if flag, protection, ok := reviewedFlagProtection(nil, "unknown flag: --time"); ok || flag != "" || protection != "" {
|
||||
t.Fatalf("nil command protection = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
installReviewedFlagProtectionHandlers(nil)
|
||||
|
||||
root := NewRootCommand()
|
||||
cmd := mustFindCommand(t, root, "chat", "message", "list-by-sender")
|
||||
flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --time=value")
|
||||
if !ok || flag != "time" || protection != "blocked" {
|
||||
t.Fatalf("delimited protected flag = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
if flag, protection, ok := reviewedFlagProtection(cmd, "unknown flag: --not-reviewed"); ok || flag != "" || protection != "" {
|
||||
t.Fatalf("unreviewed flag protection = %q, %q, %v", flag, protection, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReviewedFlagProtectionInstallerPreservesLocalHandler(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
cmd := mustFindCommand(t, root, "contact", "dept", "list-children")
|
||||
handler := cmd.FlagErrorFunc()
|
||||
|
||||
unreviewed := handler(cmd, fmt.Errorf("unknown flag: --not-reviewed"))
|
||||
var structured *apperrors.Error
|
||||
if stderrors.As(unreviewed, &structured) {
|
||||
t.Fatalf("unreviewed error bypassed the command's local handler: %#v", structured)
|
||||
}
|
||||
if !strings.HasSuffix(unreviewed.Error(), "See 'dws contact dept list-children --help' for usage.") {
|
||||
t.Fatalf("local handler output = %q", unreviewed)
|
||||
}
|
||||
|
||||
guarded := handler(cmd, fmt.Errorf("unknown flag: --name"))
|
||||
if !stderrors.As(guarded, &structured) || structured.Reason != "blocked_flag" {
|
||||
t.Fatalf("reviewed guard did not use the central handler: %#v", guarded)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
// This file holds deliberate no-op stubs that act as edition-sync anchors for
|
||||
// the private wukong edition overlay. The overlay replaces these function
|
||||
// bodies with real host-compatibility configuration while the open-source
|
||||
// build keeps them empty; both editions therefore share identical call sites
|
||||
// (access_token_resolve.go, auth_command.go, doctor_command.go,
|
||||
// force_refresh.go). These stubs are sync seams for the edition overlay, not
|
||||
// dead code: do not delete them or their call sites during cleanup.
|
||||
package app
|
||||
|
||||
import (
|
||||
|
||||
+13
-7
@@ -27,9 +27,11 @@ import (
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
// mountLegacyPublicCommands builds the product + shortcut command tree without
|
||||
// mutating process-global MCP deps or dynamic server endpoints. Used by the
|
||||
// Schema source root (declaration-only) path so assembly cannot clobber a live
|
||||
// runtime's InitDeps caller or plugin endpoints.
|
||||
func mountLegacyPublicCommands(runner executor.Runner, loadUserShortcuts bool) []*cobra.Command {
|
||||
commands := helpers.NewPublicCommands(runner)
|
||||
// Load user-defined shortcuts (~/.dws/shortcuts/*.yaml) BEFORE compiling the
|
||||
// command tree, so distilled high-frequency operations mount alongside the
|
||||
@@ -51,6 +53,14 @@ func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller,
|
||||
return mergeTopLevelCommands(commands)
|
||||
}
|
||||
|
||||
// newLegacyPublicCommands is the executable CLI path: inject static MCP
|
||||
// endpoints, InitDeps, then mount the public command tree.
|
||||
func newLegacyPublicCommands(runner executor.Runner, caller edition.ToolCaller, loadUserShortcuts bool) []*cobra.Command {
|
||||
injectStaticServers()
|
||||
helpers.InitDeps(caller)
|
||||
return mountLegacyPublicCommands(runner, loadUserShortcuts)
|
||||
}
|
||||
|
||||
func injectStaticServers() {
|
||||
hooks := edition.Get()
|
||||
var servers []edition.ServerInfo
|
||||
@@ -82,10 +92,6 @@ func injectStaticServers() {
|
||||
SetDynamicServers(descriptors)
|
||||
}
|
||||
|
||||
func newLegacyHiddenCommands(_ executor.Runner) []*cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
func mergeTopLevelCommands(commands []*cobra.Command) []*cobra.Command {
|
||||
byName := make(map[string]*cobra.Command, len(commands))
|
||||
for _, cmd := range commands {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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/contract"
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
const (
|
||||
mcpMetaServerID = "mcp-meta"
|
||||
mcpMetaURLTool = "get_mcp_server_url"
|
||||
)
|
||||
|
||||
func newMCPURLGroup(caller edition.ToolCaller) *cobra.Command {
|
||||
group := &cobra.Command{
|
||||
Use: "url",
|
||||
Short: "管理 MCP 服务连接地址",
|
||||
Args: cobra.NoArgs,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
group.AddCommand(newMCPURLGetCommand(caller))
|
||||
return group
|
||||
}
|
||||
|
||||
func newMCPURLGetCommand(caller edition.ToolCaller) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <mcpId>",
|
||||
Short: "按 mcpId 获取 MCP 的 Streamable HTTP 服务地址",
|
||||
Long: "输入 MCP 市场 mcpId,返回以当前用户和组织身份访问该 MCP 的 " +
|
||||
"Streamable HTTP 服务地址。\n\n" +
|
||||
"安全提示:返回的 mcpURL 和 mcpJSON 可能包含身份凭据,仅限个人使用," +
|
||||
"请勿分享到群聊、文档、邮件、代码仓库或日志。",
|
||||
Example: " dws mcp url get 2480\n" +
|
||||
" dws mcp url get 2480 --format json",
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if caller == nil {
|
||||
return fmt.Errorf("MCP tool caller is not configured")
|
||||
}
|
||||
mcpID := strings.TrimSpace(args[0])
|
||||
if mcpID == "" {
|
||||
return fmt.Errorf("mcpId 不能为空")
|
||||
}
|
||||
|
||||
result, err := caller.CallTool(cmd.Context(), mcpMetaServerID, mcpMetaURLTool, map[string]any{
|
||||
"mcpId": mcpID,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("获取 MCP 服务地址: %w", err)
|
||||
}
|
||||
return writeMCPURLResult(cmd, result)
|
||||
},
|
||||
}
|
||||
cli.AnnotateRuntimePositionals(cmd, contract.RuntimeSchemaPositional{
|
||||
Name: "mcp_id",
|
||||
Type: "string",
|
||||
Description: "钉钉 MCP 市场中的 mcpId",
|
||||
Required: true,
|
||||
Index: 0,
|
||||
})
|
||||
helpers.DeclareLeafMetadata(cmd, helpers.LeafSpec{
|
||||
Safety: contract.SafetySpec{
|
||||
Effect: "read", Risk: "medium",
|
||||
Confirmation: "not_required", Idempotency: "idempotent",
|
||||
},
|
||||
Contract: helpers.LeafContract{
|
||||
Identity: contract.ToolIdentitySpec{
|
||||
ProductID: "mcp",
|
||||
Name: "url_get",
|
||||
CanonicalPath: "mcp.url_get",
|
||||
CLIPath: "mcp url get",
|
||||
PrimaryCLIPath: "mcp url get",
|
||||
},
|
||||
Description: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
Interface: &contract.InterfaceSpec{
|
||||
Mode: "composite",
|
||||
Availability: "available",
|
||||
Reason: "Reviewed unpinned remote adapter: the public CLI wrapper calls the helper-only mcp-meta/get_mcp_server_url endpoint, which is intentionally absent from the public product catalog and pinned MCP metadata.",
|
||||
},
|
||||
Selection: contract.SelectionSpec{
|
||||
AgentSummary: "按 MCP 市场 mcpId 获取当前用户和组织可用的 Streamable HTTP 地址",
|
||||
UseWhen: []string{"已知钉钉 MCP 市场 mcpId,需要获得当前身份可用的 Streamable HTTP 连接地址"},
|
||||
AvoidWhen: []string{
|
||||
"只是查询 DWS 已公开命令或参数时使用 dws schema",
|
||||
"用户要求把返回的凭据 URL 发送到群聊、文档、邮件、日志或代码仓库时不要执行或传播",
|
||||
},
|
||||
Examples: []string{"dws mcp url get 10043 --format json"},
|
||||
},
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func writeMCPURLResult(cmd *cobra.Command, result *edition.ToolResult) error {
|
||||
if result == nil {
|
||||
return fmt.Errorf("MCP 元服务返回空结果")
|
||||
}
|
||||
// get_mcp_server_url returns one JSON document in its first non-empty text
|
||||
// block. Other block types and trailing blocks are intentionally ignored.
|
||||
for _, block := range result.Content {
|
||||
if block.Type != "text" || strings.TrimSpace(block.Text) == "" {
|
||||
continue
|
||||
}
|
||||
if err := apperrors.ClassifyMCPResponseText(block.Text); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var payload any
|
||||
if err := json.Unmarshal([]byte(block.Text), &payload); err != nil {
|
||||
return fmt.Errorf("MCP 元服务返回了无效 JSON: %w", err)
|
||||
}
|
||||
return output.WriteCommandPayload(cmd, payload, output.FormatJSON)
|
||||
}
|
||||
return fmt.Errorf("MCP 元服务返回空结果")
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type mcpURLTestCaller struct {
|
||||
productID string
|
||||
toolName string
|
||||
args map[string]any
|
||||
result *edition.ToolResult
|
||||
err error
|
||||
}
|
||||
|
||||
func (c *mcpURLTestCaller) CallTool(_ context.Context, productID, toolName string, args map[string]any) (*edition.ToolResult, error) {
|
||||
c.productID = productID
|
||||
c.toolName = toolName
|
||||
c.args = args
|
||||
return c.result, c.err
|
||||
}
|
||||
|
||||
func (*mcpURLTestCaller) Format() string { return "json" }
|
||||
func (*mcpURLTestCaller) DryRun() bool { return false }
|
||||
func (*mcpURLTestCaller) Fields() string { return "" }
|
||||
func (*mcpURLTestCaller) JQ() string { return "" }
|
||||
|
||||
func executeMCPURLCommand(t *testing.T, caller edition.ToolCaller, args ...string) (string, error) {
|
||||
t.Helper()
|
||||
root := &cobra.Command{Use: "mcp", SilenceErrors: true, SilenceUsage: true}
|
||||
root.AddCommand(newMCPURLGroup(caller))
|
||||
var out bytes.Buffer
|
||||
root.SetOut(&out)
|
||||
root.SetErr(&out)
|
||||
root.SetArgs(args)
|
||||
err := root.ExecuteContext(t.Context())
|
||||
return out.String(), err
|
||||
}
|
||||
|
||||
func TestMCPURLGetCallsMetaServerAndPreservesResponse(t *testing.T) {
|
||||
const response = `{"result":{"mcpURL":"https://example.test/mcp?key=one&token=two","mcpJSON":{"transport":"streamable-http"},"name":"Example"}}`
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: response}}},
|
||||
}
|
||||
|
||||
out, err := executeMCPURLCommand(t, caller, "url", "get", " 10043 ")
|
||||
if err != nil {
|
||||
t.Fatalf("execute mcp url get: %v", err)
|
||||
}
|
||||
if caller.productID != mcpMetaServerID {
|
||||
t.Fatalf("productID = %q, want %q", caller.productID, mcpMetaServerID)
|
||||
}
|
||||
if caller.toolName != mcpMetaURLTool {
|
||||
t.Fatalf("toolName = %q, want %q", caller.toolName, mcpMetaURLTool)
|
||||
}
|
||||
if got := caller.args["mcpId"]; got != "10043" {
|
||||
t.Fatalf("mcpId = %#v, want %q", got, "10043")
|
||||
}
|
||||
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &payload); err != nil {
|
||||
t.Fatalf("output is not JSON: %v\n%s", err, out)
|
||||
}
|
||||
result, ok := payload["result"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("output result = %#v", payload["result"])
|
||||
}
|
||||
if got := result["mcpURL"]; got != "https://example.test/mcp?key=one&token=two" {
|
||||
t.Fatalf("result.mcpURL = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsBlankID(t *testing.T) {
|
||||
_, err := executeMCPURLCommand(t, &mcpURLTestCaller{}, "url", "get", " ")
|
||||
if err == nil || !strings.Contains(err.Error(), "mcpId 不能为空") {
|
||||
t.Fatalf("error = %v, want blank mcpId error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGroupShowsHelp(t *testing.T) {
|
||||
out, err := executeMCPURLCommand(t, nil, "url")
|
||||
if err != nil {
|
||||
t.Fatalf("execute mcp url: %v", err)
|
||||
}
|
||||
if !strings.Contains(out, "get") {
|
||||
t.Fatalf("help output does not list get command:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsMissingCaller(t *testing.T) {
|
||||
_, err := executeMCPURLCommand(t, nil, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "caller is not configured") {
|
||||
t.Fatalf("error = %v, want missing caller error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetPropagatesCallError(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{err: errors.New("permission denied")}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("error = %v, want call error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsInvalidJSON(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: "not-json"}}},
|
||||
}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "无效 JSON") {
|
||||
t.Fatalf("error = %v, want invalid JSON error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetRejectsEmptyResults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
result *edition.ToolResult
|
||||
}{
|
||||
{name: "nil result"},
|
||||
{
|
||||
name: "no usable text content",
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{
|
||||
{Type: "image", Text: "ignored"},
|
||||
{Type: "text", Text: " "},
|
||||
}},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{result: tt.result}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil || !strings.Contains(err.Error(), "返回空结果") {
|
||||
t.Fatalf("error = %v, want empty result error", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPURLGetClassifiesBusinessError(t *testing.T) {
|
||||
caller := &mcpURLTestCaller{
|
||||
result: &edition.ToolResult{Content: []edition.ContentBlock{{
|
||||
Type: "text",
|
||||
Text: `{"success":false,"errorMsg":"搜索内容不能为空"}`,
|
||||
}}},
|
||||
}
|
||||
_, err := executeMCPURLCommand(t, caller, "url", "get", "10043")
|
||||
if err == nil {
|
||||
t.Fatal("expected classified business error")
|
||||
}
|
||||
var typed *apperrors.Error
|
||||
if !errors.As(err, &typed) || typed.Reason != "business_error" {
|
||||
t.Fatalf("error = %#v, want classified business error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRootRegistersMCPURLGet(t *testing.T) {
|
||||
root := NewRootCommand(t.Context())
|
||||
mcp, _, err := root.Find([]string{"mcp"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mcp: %v", err)
|
||||
}
|
||||
if mcp.Hidden {
|
||||
t.Fatal("mcp command must be public when it contains reviewed public helpers")
|
||||
}
|
||||
cmd, _, err := root.Find([]string{"mcp", "url", "get"})
|
||||
if err != nil {
|
||||
t.Fatalf("find mcp url get: %v", err)
|
||||
}
|
||||
if got := cmd.CommandPath(); got != "dws mcp url get" {
|
||||
t.Fatalf("command path = %q, want %q", got, "dws mcp url get")
|
||||
}
|
||||
}
|
||||
@@ -107,6 +107,45 @@ func TestRuntimeRunnerDeduplicatesByResolvedIdentityInSameCorp(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRuntimeRunnerDeduplicatesReservedAndOrganizationAliasesForBlankProfile(t *testing.T) {
|
||||
exact := authLogoutTestToken("corp_blank_alias")
|
||||
exact.UserID = "identity_exact_alias"
|
||||
configDir := setupAuthLogoutProfiles(t, exact)
|
||||
blank := authLogoutTestToken("corp_blank_alias")
|
||||
blank.AccessToken = "access-unresolved-alias"
|
||||
blank.RefreshToken = "refresh-unresolved-alias"
|
||||
blank.UserID = ""
|
||||
blank.UserName = ""
|
||||
if err := authpkg.SaveTokenData(configDir, blank); err != nil {
|
||||
t.Fatalf("SaveTokenData(blank) error = %v", err)
|
||||
}
|
||||
cfg, err := authpkg.LoadProfiles(configDir)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadProfiles() error = %v", err)
|
||||
}
|
||||
var reserved string
|
||||
for _, profile := range cfg.Profiles {
|
||||
if profile.CorpID == blank.CorpID && profile.UserID == "" {
|
||||
reserved = authpkg.ProfileSelectionSelector(profile, cfg)
|
||||
break
|
||||
}
|
||||
}
|
||||
if reserved == "" || reserved == blank.CorpID {
|
||||
t.Fatalf("blank selector = %q, want reserved selector", reserved)
|
||||
}
|
||||
|
||||
selections, multi, err := resolveMultiProfileSelections(configDir, reserved+","+blank.CorpID)
|
||||
if err != nil {
|
||||
t.Fatalf("resolveMultiProfileSelections() error = %v", err)
|
||||
}
|
||||
if !multi || len(selections) != 1 {
|
||||
t.Fatalf("blank aliases = multi %v selections %#v, want one identity", multi, selections)
|
||||
}
|
||||
if selections[0].Selector != reserved || selections[0].Profile.UserID != "" {
|
||||
t.Fatalf("blank selection = %#v, want first reserved alias preserved", selections[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeRunnerKeepsSingleProfileBehavior(t *testing.T) {
|
||||
setupAuthLogoutProfiles(t, authLogoutTestToken("corp_a"), authLogoutTestToken("corp_b"))
|
||||
authpkg.SetRuntimeProfile("corp_a")
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestMultiSkillSharedContractKeepsAccountSafetyRule pins the multi-account
|
||||
// safety rule that release run 30437390088 found missing: the MultiSkill e2e
|
||||
// contract asserts the exact phrase below inside the installed
|
||||
// dingtalk-shared/SKILL.md, so removing it from the embedded skill source must
|
||||
// fail at PR time instead of at release time.
|
||||
func TestMultiSkillSharedContractKeepsAccountSafetyRule(t *testing.T) {
|
||||
dir, cleanup, err := materializeEmbeddedSkillSource(skillSetupModeMulti)
|
||||
if err != nil {
|
||||
t.Fatalf("materialize embedded multi skill source: %v", err)
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
data, err := os.ReadFile(filepath.Join(dir, "dingtalk-shared", "SKILL.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read embedded dingtalk-shared/SKILL.md: %v", err)
|
||||
}
|
||||
const rule = "禁止选择第一项、最近登录或最近使用账号"
|
||||
if !strings.Contains(string(data), rule) {
|
||||
t.Fatalf("embedded dingtalk-shared/SKILL.md lost the mandatory account safety rule %q", rule)
|
||||
}
|
||||
}
|
||||
@@ -33,43 +33,43 @@ func contains(ss []string, want string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// dws-shared must ship even when --skill narrows the set to a single product.
|
||||
// dingtalk-shared must ship even when --skill narrows the set to a single product.
|
||||
func TestP1SharedAlwaysIncludedWithSkillFilter(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dws-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
src := writeMultiSkillSrc(t, "dingtalk-shared", "dingtalk-aitable", "dingtalk-calendar")
|
||||
all, err := listMultiSkillNames(src)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !contains(all, "dws-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dws-shared: %v", all)
|
||||
if !contains(all, "dingtalk-shared") {
|
||||
t.Fatalf("listMultiSkillNames did not enumerate dingtalk-shared: %v", all)
|
||||
}
|
||||
filtered, err := filterMultiSkillNames(all, []string{"aitable"}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if contains(filtered, "dws-shared") {
|
||||
t.Fatalf("precondition: filter should drop dws-shared for -s aitable: %v", filtered)
|
||||
if contains(filtered, "dingtalk-shared") {
|
||||
t.Fatalf("precondition: filter should drop dingtalk-shared for -s aitable: %v", filtered)
|
||||
}
|
||||
final := ensureMandatorySharedSkill(filtered, all)
|
||||
if !contains(final, "dws-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dws-shared: %v", final)
|
||||
if !contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("ensureMandatorySharedSkill must re-add dingtalk-shared: %v", final)
|
||||
}
|
||||
|
||||
// Actually install with the filtered+mandatory set and assert dws-shared landed.
|
||||
// Actually install with the filtered+mandatory set and assert dingtalk-shared landed.
|
||||
dest := t.TempDir()
|
||||
var out, errOut bytes.Buffer
|
||||
if _, _, err := installMultiSkillToHomes(src, final, []string{dest}, &out, &errOut); err != nil {
|
||||
t.Fatalf("install: %v (%s)", err, errOut.String())
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dws-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dws-shared not installed with -s aitable: %v", err)
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-shared", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-shared not installed with -s aitable: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dest, "dingtalk-aitable", "SKILL.md")); err != nil {
|
||||
t.Fatalf("dingtalk-aitable not installed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// When the source has no dws-shared (older layout), nothing is forced.
|
||||
// When the source has no dingtalk-shared (older layout), nothing is forced.
|
||||
func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
src := writeMultiSkillSrc(t, "dingtalk-aitable")
|
||||
all, err := listMultiSkillNames(src)
|
||||
@@ -77,7 +77,7 @@ func TestP1SharedNoopWhenAbsent(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
final := ensureMandatorySharedSkill([]string{"dingtalk-aitable"}, all)
|
||||
if contains(final, "dws-shared") {
|
||||
t.Fatalf("must not invent dws-shared when source lacks it: %v", final)
|
||||
if contains(final, "dingtalk-shared") {
|
||||
t.Fatalf("must not invent dingtalk-shared when source lacks it: %v", final)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
stderrors "errors"
|
||||
"io"
|
||||
"os"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
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"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/edition"
|
||||
)
|
||||
|
||||
type paramAliasToolCall struct {
|
||||
server string
|
||||
tool string
|
||||
args map[string]any
|
||||
}
|
||||
|
||||
type paramAliasCaptureCaller struct {
|
||||
calls []paramAliasToolCall
|
||||
}
|
||||
|
||||
func (c *paramAliasCaptureCaller) CallTool(_ context.Context, server, tool string, args map[string]any) (*edition.ToolResult, error) {
|
||||
copyArgs := make(map[string]any, len(args))
|
||||
for key, value := range args {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
c.calls = append(c.calls, paramAliasToolCall{server: server, tool: tool, args: copyArgs})
|
||||
text := c.paramAliasResponseForTool(tool)
|
||||
return &edition.ToolResult{Content: []edition.ContentBlock{{Type: "text", Text: text}}}, nil
|
||||
}
|
||||
|
||||
// paramAliasResponseForTool supplies deterministic, business-shape-valid
|
||||
// responses for the complete-command equivalence matrix. Most commands only
|
||||
// print the transport result and need an empty object; smart shortcuts that
|
||||
// inspect a read response receive the smallest shape that lets their full RunE
|
||||
// complete without falling back to a validation error.
|
||||
func (c *paramAliasCaptureCaller) paramAliasResponseForTool(tool string) string {
|
||||
switch tool {
|
||||
case "list_calendar_events":
|
||||
return `{"result":{"events":[]}}`
|
||||
case "search_mail_users":
|
||||
return `{"users":[{"name":"Fixture User","email":"fixture@example.com","id":"fixture-user"}]}`
|
||||
case "search_dept_by_keyword":
|
||||
return `{"deptList":[{"deptId":1,"name":"Fixture Dept"}]}`
|
||||
case "search_groups":
|
||||
return `{"result":{"items":[{"openConversationId":"fixture-conversation","title":"Fixture Group"}]}}`
|
||||
case "search_contact_by_key_word":
|
||||
return `{"result":[{"name":"Fixture User","userId":"fixture-user","openDingTalkId":"D-fixture-user"}]}`
|
||||
case "list_doc_versions":
|
||||
return `{"result":{"items":[{"version":3}]}}`
|
||||
case "revert_doc_version":
|
||||
return `{"version":3}`
|
||||
case "search_doc_templates":
|
||||
return `{"result":[{"templateId":"fixture-template-id"}]}`
|
||||
case "create_document":
|
||||
return `{"nodeId":"fixture-node"}`
|
||||
case "get_document_content":
|
||||
for index := len(c.calls) - 2; index >= 0; index-- {
|
||||
call := c.calls[index]
|
||||
for _, key := range []string{"jsonml", "markdown"} {
|
||||
if content, ok := call.args[key].(string); ok {
|
||||
encoded, _ := json.Marshal(map[string]any{"revision": 1, key: content})
|
||||
return string(encoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
return `{"revision":1}`
|
||||
default:
|
||||
return `{}`
|
||||
}
|
||||
}
|
||||
|
||||
func (*paramAliasCaptureCaller) Format() string { return "json" }
|
||||
func (*paramAliasCaptureCaller) DryRun() bool { return false }
|
||||
func (*paramAliasCaptureCaller) Fields() string { return "" }
|
||||
func (*paramAliasCaptureCaller) JQ() string { return "" }
|
||||
|
||||
// paramAliasCaptureRunner covers helpers (currently dev app) that dispatch
|
||||
// through executor.Runner instead of edition.ToolCaller. Keeping both capture
|
||||
// boundaries in one call list lets the matrix compare the final request shape
|
||||
// without knowing which transport adapter a command uses.
|
||||
type paramAliasCaptureRunner struct {
|
||||
caller *paramAliasCaptureCaller
|
||||
}
|
||||
|
||||
func (r *paramAliasCaptureRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
copyArgs := make(map[string]any, len(invocation.Params))
|
||||
for key, value := range invocation.Params {
|
||||
copyArgs[key] = value
|
||||
}
|
||||
r.caller.calls = append(r.caller.calls, paramAliasToolCall{
|
||||
server: invocation.CanonicalProduct,
|
||||
tool: invocation.Tool,
|
||||
args: copyArgs,
|
||||
})
|
||||
invocation.Implemented = true
|
||||
return executor.Result{Invocation: invocation, Response: map[string]any{}}, nil
|
||||
}
|
||||
|
||||
type paramAliasDryRunRejectRunner struct {
|
||||
attempts []executor.Invocation
|
||||
}
|
||||
|
||||
func (r *paramAliasDryRunRejectRunner) Run(_ context.Context, invocation executor.Invocation) (executor.Result, error) {
|
||||
r.attempts = append(r.attempts, invocation)
|
||||
return executor.Result{}, stderrors.New("dry-run reached the injected command runner")
|
||||
}
|
||||
|
||||
type paramAliasDryRunPreview struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Executed bool `json:"executed"`
|
||||
Tool string `json:"tool"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// executeParamAliasDryRunE2E uses the existing root --dry-run barrier as a
|
||||
// parameter-normalization probe. These commands do not publish command-owned
|
||||
// dry-run capabilities in Schema; the test deliberately makes no such claim.
|
||||
// A reject runner proves the preview stops before endpoint resolution,
|
||||
// authentication, or transport execution.
|
||||
func executeParamAliasDryRunE2E(t *testing.T, args ...string) (*pipeline.Context, paramAliasDryRunPreview, []executor.Invocation, error) {
|
||||
t.Helper()
|
||||
|
||||
originalArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
captureFile, err := os.CreateTemp(t.TempDir(), "param-alias-dry-run-*.json")
|
||||
if err != nil {
|
||||
t.Fatalf("create dry-run output capture: %v", err)
|
||||
}
|
||||
defer captureFile.Close()
|
||||
originalStdout := os.Stdout
|
||||
originalCaller := helpers.GetCaller()
|
||||
os.Stdout = captureFile
|
||||
defer func() {
|
||||
os.Stdout = originalStdout
|
||||
helpers.InitDeps(originalCaller)
|
||||
}()
|
||||
rejectRunner := ¶mAliasDryRunRejectRunner{}
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return rejectRunner
|
||||
}
|
||||
root := NewRootCommand()
|
||||
rootNewCommandRunnerWithFlags = originalRunnerFactory
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
|
||||
ctx, executeErr := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if executeErr == nil {
|
||||
executeErr = root.Execute()
|
||||
}
|
||||
|
||||
if err := captureFile.Sync(); err != nil {
|
||||
t.Fatalf("sync dry-run output capture: %v", err)
|
||||
}
|
||||
if _, err := captureFile.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatalf("rewind dry-run output capture: %v", err)
|
||||
}
|
||||
output, err := io.ReadAll(captureFile)
|
||||
if err != nil {
|
||||
t.Fatalf("read dry-run output capture: %v", err)
|
||||
}
|
||||
var preview paramAliasDryRunPreview
|
||||
if executeErr == nil {
|
||||
if err := json.Unmarshal(output, &preview); err != nil {
|
||||
t.Fatalf("decode dry-run preview: %v\noutput=%s", err, output)
|
||||
}
|
||||
}
|
||||
return ctx, preview, append([]executor.Invocation(nil), rejectRunner.attempts...), executeErr
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagListDryRunStopsBeforeReadDispatch(t *testing.T) {
|
||||
_, preview, attempts, err := executeParamAliasDryRunE2E(t,
|
||||
"chat", "+flag-list", "--page-size", "20", "--cursor", "0", "--dry-run",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("flag-list dry-run error = %v", err)
|
||||
}
|
||||
if len(attempts) != 0 {
|
||||
t.Fatalf("flag-list dry-run crossed dispatch boundary: %#v", attempts)
|
||||
}
|
||||
if !preview.DryRun || preview.Executed || preview.Tool != "list_message_favorites" {
|
||||
t.Fatalf("flag-list dry-run preview = %#v", preview)
|
||||
}
|
||||
if preview.Arguments["cursor"] != float64(0) || preview.Arguments["size"] != "20" {
|
||||
t.Fatalf("flag-list dry-run arguments = %#v", preview.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
func executeParamAliasE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
originalArgs := os.Args
|
||||
os.Args = append([]string{"dws"}, args...)
|
||||
defer func() { os.Args = originalArgs }()
|
||||
|
||||
originalRunnerFactory := rootNewCommandRunnerWithFlags
|
||||
rootNewCommandRunnerWithFlags = func(*GlobalFlags) executor.Runner {
|
||||
return ¶mAliasCaptureRunner{caller: caller}
|
||||
}
|
||||
root := NewRootCommand()
|
||||
rootNewCommandRunnerWithFlags = originalRunnerFactory
|
||||
root.SetOut(io.Discard)
|
||||
root.SetErr(io.Discard)
|
||||
root.SetArgs(args)
|
||||
originalCaller := helpers.GetCaller()
|
||||
helpers.InitDeps(caller)
|
||||
defer helpers.InitDeps(originalCaller)
|
||||
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
if err != nil {
|
||||
return ctx, err
|
||||
}
|
||||
return ctx, root.Execute()
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageBooleanStickyCannotBypassDestructiveConfirmation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
confirmation []string
|
||||
wantError string
|
||||
wantCalls int
|
||||
wantOriginal string
|
||||
wantCorrection string
|
||||
}{
|
||||
{name: "bare yes confirms", confirmation: []string{"--yes"}, wantCalls: 1},
|
||||
{name: "glued false stays unconfirmed", confirmation: []string{"--yesfalse"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yesfalse", wantCorrection: "--yes=false"},
|
||||
{name: "glued true confirms", confirmation: []string{"--yestrue"}, wantCalls: 1, wantOriginal: "--yestrue", wantCorrection: "--yes=true"},
|
||||
{name: "detached false stays unconfirmed", confirmation: []string{"--yes", "false"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes false", wantCorrection: "--yes=false"},
|
||||
{name: "detached no stays unconfirmed", confirmation: []string{"--yes", "no"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes no", wantCorrection: "--yes=false"},
|
||||
{name: "detached zero stays unconfirmed", confirmation: []string{"--yes", "0"}, wantError: "请添加 --yes 确认执行", wantOriginal: "--yes 0", wantCorrection: "--yes=false"},
|
||||
{name: "detached true confirms", confirmation: []string{"--yes", "true"}, wantCalls: 1, wantOriginal: "--yes true", wantCorrection: "--yes=true"},
|
||||
{name: "detached yes confirms", confirmation: []string{"--yes", "yes"}, wantCalls: 1, wantOriginal: "--yes yes", wantCorrection: "--yes=true"},
|
||||
{name: "detached one confirms", confirmation: []string{"--yes", "1"}, wantCalls: 1, wantOriginal: "--yes 1", wantCorrection: "--yes=true"},
|
||||
{name: "explicit false remains unconfirmed", confirmation: []string{"--yes=false"}, wantError: "请添加 --yes 确认执行"},
|
||||
{name: "explicit true confirms", confirmation: []string{"--yes=true"}, wantCalls: 1},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
args := []string{
|
||||
"mail", "thread", "trash",
|
||||
"--email", "user@example.com",
|
||||
"--id", "conversation-1",
|
||||
}
|
||||
args = append(args, test.confirmation...)
|
||||
ctx, err := executeParamAliasE2E(t, caller, args...)
|
||||
if test.wantError == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("confirmed command error = %v", err)
|
||||
}
|
||||
} else if err == nil || !strings.Contains(err.Error(), test.wantError) {
|
||||
t.Fatalf("command error = %v, want substring %q", err, test.wantError)
|
||||
}
|
||||
if test.wantCorrection == "" {
|
||||
if ctx != nil && len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("confirmation spelling received corrections: %#v", ctx.Corrections)
|
||||
}
|
||||
} else if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != test.wantOriginal || ctx.Corrections[0].Corrected != test.wantCorrection {
|
||||
t.Fatalf("confirmation corrections = %#v, want %q -> %q", ctx, test.wantOriginal, test.wantCorrection)
|
||||
}
|
||||
if len(caller.calls) != test.wantCalls {
|
||||
t.Fatalf("destructive calls = %#v, want %d", caller.calls, test.wantCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParamAliasReadCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
start := "2026-03-10T14:00:00+08:00"
|
||||
end := "2026-03-10T18:00:00+08:00"
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"calendar", "event", "list",
|
||||
"--date", start,
|
||||
"--end-time", end,
|
||||
"--calendar", "primary",
|
||||
"--max-results", "7",
|
||||
"--next-cursor", "cursor-1",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("calendar alias E2E error = %v", err)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--date" || ctx.Corrections[0].Corrected != "--start" {
|
||||
t.Fatalf("calendar corrections = %#v, want only --date to be normalized centrally", ctx.Corrections)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "list_calendar_events" {
|
||||
t.Fatalf("calendar calls = %#v", caller.calls)
|
||||
}
|
||||
startMS, _ := cmdutil.ParseISOTimeToMillis("start", start)
|
||||
endMS, _ := cmdutil.ParseISOTimeToMillis("end", end)
|
||||
want := map[string]any{
|
||||
"startTime": startMS,
|
||||
"endTime": endMS,
|
||||
"calendarId": "primary",
|
||||
"limit": 7,
|
||||
"cursor": "cursor-1",
|
||||
}
|
||||
if !reflect.DeepEqual(caller.calls[0].args, want) {
|
||||
t.Fatalf("calendar payload = %#v, want %#v", caller.calls[0].args, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParamAliasWriteCommandFinalPayload(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, caller,
|
||||
"chat", "message", "send",
|
||||
"--to-user", "D-recipient",
|
||||
"--text", "hello alias",
|
||||
"--uuid", "alias-e2e",
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("chat write alias E2E error = %v", err)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--to-user" || ctx.Corrections[0].Corrected != "--user" {
|
||||
t.Fatalf("chat corrections = %#v", ctx.Corrections)
|
||||
}
|
||||
if len(caller.calls) != 1 || caller.calls[0].tool != "send_personal_message" {
|
||||
t.Fatalf("chat calls = %#v", caller.calls)
|
||||
}
|
||||
payload := caller.calls[0].args
|
||||
if payload["receiverOpenDingTalkId"] != "D-recipient" || payload["uuid"] != "alias-e2e" || payload["msgType"] != "markdown" {
|
||||
t.Fatalf("chat payload identity fields = %#v", payload)
|
||||
}
|
||||
content, _ := payload["content"].(string)
|
||||
if !strings.Contains(content, "hello alias") {
|
||||
t.Fatalf("chat payload content = %q", content)
|
||||
}
|
||||
for _, forbidden := range []string{"user", "to-user", "userId"} {
|
||||
if _, exists := payload[forbidden]; exists {
|
||||
t.Fatalf("chat payload leaked pre-normalization field %q: %#v", forbidden, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageChatReactionConversationAliasesReachCanonicalPayload(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
command []string
|
||||
tool string
|
||||
required []string
|
||||
}{
|
||||
{
|
||||
name: "add emoji",
|
||||
command: []string{"chat", "message", "add-emoji"},
|
||||
tool: "add_emoji_reaction",
|
||||
required: []string{"--msg-id", "message-1", "--emoji", "like"},
|
||||
},
|
||||
{
|
||||
name: "remove emoji",
|
||||
command: []string{"chat", "message", "remove-emoji"},
|
||||
tool: "remove_emoji_reaction",
|
||||
required: []string{"--msg-id", "message-1", "--emoji", "like"},
|
||||
},
|
||||
{
|
||||
name: "add text emotion",
|
||||
command: []string{"chat", "message", "add-text-emotion"},
|
||||
tool: "add_text_emotion",
|
||||
required: []string{
|
||||
"--msg-id", "message-1", "--emotion-id", "emotion-1",
|
||||
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "remove text emotion",
|
||||
command: []string{"chat", "message", "remove-text-emotion"},
|
||||
tool: "remove_text_emotion",
|
||||
required: []string{
|
||||
"--msg-id", "message-1", "--emotion-id", "emotion-1",
|
||||
"--emotion-name", "like", "--text", "nice", "--background-id", "background-1",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
canonicalArgs := append([]string(nil), test.command...)
|
||||
canonicalArgs = append(canonicalArgs, "--conversation-id", "conversation-1")
|
||||
canonicalArgs = append(canonicalArgs, test.required...)
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
if _, err := executeParamAliasE2E(t, canonicalCaller, canonicalArgs...); err != nil {
|
||||
t.Fatalf("canonical execution failed: %v", err)
|
||||
}
|
||||
if len(canonicalCaller.calls) != 1 || canonicalCaller.calls[0].tool != test.tool {
|
||||
t.Fatalf("canonical calls = %#v, want one %s call", canonicalCaller.calls, test.tool)
|
||||
}
|
||||
if canonicalCaller.calls[0].args["openConversationId"] != "conversation-1" {
|
||||
t.Fatalf("canonical payload = %#v", canonicalCaller.calls[0].args)
|
||||
}
|
||||
|
||||
// Numeric --group-id is a different identifier domain and is covered
|
||||
// by TestAllReviewedParamAliasGuardsReachRuntimeContract.
|
||||
for _, alias := range []string{"chat-id", "open-conversation-id"} {
|
||||
t.Run(alias, func(t *testing.T) {
|
||||
aliasArgs := append([]string(nil), test.command...)
|
||||
aliasArgs = append(aliasArgs, "--"+alias, "conversation-1")
|
||||
aliasArgs = append(aliasArgs, test.required...)
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, err := executeParamAliasE2E(t, aliasCaller, aliasArgs...)
|
||||
if err != nil {
|
||||
t.Fatalf("alias execution failed: %v", err)
|
||||
}
|
||||
if ctx == nil || len(ctx.Corrections) != 1 || ctx.Corrections[0].Original != "--"+alias || ctx.Corrections[0].Corrected != "--conversation-id" {
|
||||
t.Fatalf("alias corrections = %#v", ctx)
|
||||
}
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final calls differ\ncanonical=%#v\nalias=%#v", canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAllGeneratedChatParamAliasesReachRuntimeCobraContract(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
entries, err := cli.ReduceParamAliases(root)
|
||||
if err != nil {
|
||||
t.Fatalf("ReduceParamAliases() error = %v", err)
|
||||
}
|
||||
|
||||
chatEntries := 0
|
||||
aliasCases := 0
|
||||
guardCases := map[pipeline.FlagProtection]int{}
|
||||
for _, entry := range entries {
|
||||
if !strings.HasPrefix(entry.CLIPath, "chat ") {
|
||||
continue
|
||||
}
|
||||
chatEntries++
|
||||
leaf := resolveParamLeaf(root, entry.CLIPath)
|
||||
if leaf == nil {
|
||||
t.Fatalf("generated chat parameter path %q is not runnable", entry.CLIPath)
|
||||
}
|
||||
|
||||
aliases := make([]string, 0, len(entry.Aliases))
|
||||
for emitted := range entry.Aliases {
|
||||
aliases = append(aliases, emitted)
|
||||
}
|
||||
sort.Strings(aliases)
|
||||
for _, emitted := range aliases {
|
||||
emitted := emitted
|
||||
canonical := entry.Aliases[emitted]
|
||||
aliasCases++
|
||||
t.Run(entry.CLIPath+"/alias/"+emitted, func(t *testing.T) {
|
||||
value := paramFixtureValue(leaf, emitted, canonical)
|
||||
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
|
||||
if len(flagArgs) < 2 || flagArgs[0] != "--"+canonical || flagArgs[1] != value {
|
||||
t.Fatalf("runtime alias %q => %q produced args %v", emitted, canonical, ctx.Args)
|
||||
}
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr != nil {
|
||||
t.Fatalf("canonical Cobra ParseFlags(%v) error = %v", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, guard := range []struct {
|
||||
protection pipeline.FlagProtection
|
||||
emitted []string
|
||||
}{
|
||||
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
|
||||
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
|
||||
} {
|
||||
for _, emitted := range guard.emitted {
|
||||
emitted := emitted
|
||||
protection := guard.protection
|
||||
guardCases[protection]++
|
||||
t.Run(entry.CLIPath+"/"+string(protection)+"/"+emitted, func(t *testing.T) {
|
||||
value := paramFixtureValue(leaf, emitted, "did-you-mean:"+string(protection))
|
||||
rawArgs := append(strings.Fields(entry.CLIPath), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, runErr)
|
||||
}
|
||||
morphed := cmdutil.Morph(emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != protection {
|
||||
t.Fatalf("runtime guard %q protection = %#v, want %s", emitted, ctx, protection)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, emitted, value)
|
||||
flagArgs := ctx.Args[len(strings.Fields(entry.CLIPath)):]
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
|
||||
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if chatEntries == 0 || aliasCases == 0 || guardCases[pipeline.FlagProtectionBlocked] == 0 || guardCases[pipeline.FlagProtectionAmbiguous] == 0 {
|
||||
t.Fatalf("chat parameter coverage is vacuous: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
t.Logf("verified generated chat parameter routes: entries=%d aliases=%d blocked=%d ambiguous=%d", chatEntries, aliasCases, guardCases[pipeline.FlagProtectionBlocked], guardCases[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageIMUserIDHallucinationRoutes(t *testing.T) {
|
||||
tests := []struct {
|
||||
command string
|
||||
want string
|
||||
}{
|
||||
// These paths are reduced by the reviewed user_id concept.
|
||||
{command: "chat +chat-role-query-user", want: "user"},
|
||||
{command: "chat +chat-role-set-user", want: "user"},
|
||||
{command: "chat +messages-list-direct", want: "user"},
|
||||
{command: "chat chmod", want: "user"},
|
||||
{command: "chat message list", want: "user"},
|
||||
{command: "chat message send", want: "user"},
|
||||
|
||||
// These commands already own a hidden --userId compatibility flag.
|
||||
// The format/spelling handler rewrites --user-id to that real flag, and
|
||||
// the command's existing flagOrFallback wiring preserves its semantics.
|
||||
{command: "chat conversation-info", want: "userId"},
|
||||
{command: "chat group transfer-owner", want: "userId"},
|
||||
{command: "chat group-role query-user", want: "userId"},
|
||||
{command: "chat group-role remove-user", want: "userId"},
|
||||
{command: "chat group-role set-user", want: "userId"},
|
||||
{command: "chat group set-admin", want: "userId"},
|
||||
{command: "chat group-mute-member", want: "userId"},
|
||||
{command: "chat message read-status", want: "userId"},
|
||||
{command: "chat message search-advanced", want: "userId"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.command, func(t *testing.T) {
|
||||
root := NewRootCommand()
|
||||
leaf := resolveParamLeaf(root, test.command)
|
||||
if leaf == nil {
|
||||
t.Fatalf("IM command %q is not runnable", test.command)
|
||||
}
|
||||
rawArgs := append(strings.Fields(test.command), "--user-id", "fixture-user")
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(test.command)):]
|
||||
if len(flagArgs) != 2 || flagArgs[0] != "--"+test.want || flagArgs[1] != "fixture-user" {
|
||||
t.Fatalf("--user-id route = %v, want --%s fixture-user", flagArgs, test.want)
|
||||
}
|
||||
if err := leaf.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageHiddenIMListDirectRemainsOutsideCentralAliasTable(t *testing.T) {
|
||||
const command = "chat message list-direct"
|
||||
if _, ok := cli.LookupParamAlias(command); ok {
|
||||
t.Fatalf("hidden command %q unexpectedly entered the public generated alias table", command)
|
||||
}
|
||||
|
||||
root := NewRootCommand()
|
||||
leaf := resolveParamLeaf(root, command)
|
||||
if leaf == nil || !leaf.Hidden {
|
||||
t.Fatalf("%q must remain a live hidden compatibility command", command)
|
||||
}
|
||||
rawArgs := append(strings.Fields(command), "--user-id", "fixture-user")
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", rawArgs, err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs returned nil context")
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(command)):]
|
||||
if err := leaf.ParseFlags(flagArgs); err == nil || !strings.Contains(err.Error(), "unknown flag") {
|
||||
t.Fatalf("hidden command ParseFlags(%v) error = %v, want unknown flag", flagArgs, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageSelectedParamAliasesProduceCanonicalEquivalentDryRunPreviews(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
tool string
|
||||
canonicalArgs []string
|
||||
aliasArgs []string
|
||||
wantCorrections int
|
||||
wantArgKeys []string
|
||||
}{
|
||||
{
|
||||
name: "calendar read with multiple aliases",
|
||||
tool: "list_calendar_events",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--start", "2026-03-10T14:00:00+08:00",
|
||||
"--end", "2026-03-10T18:00:00+08:00",
|
||||
"--calendar-id", "primary", "--limit", "7", "--cursor", "cursor-1",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "calendar", "event", "list",
|
||||
"--date", "2026-03-10T14:00:00+08:00",
|
||||
"--end-time", "2026-03-10T18:00:00+08:00",
|
||||
"--calendar", "primary", "--max-results", "7", "--next-cursor", "cursor-1",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"calendarId", "cursor", "endTime", "limit", "startTime"},
|
||||
},
|
||||
{
|
||||
name: "chat write scoped recipient alias",
|
||||
tool: "send_personal_message",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "chat", "message", "send",
|
||||
"--to-user", "D-recipient", "--text", "hello dry-run", "--uuid", "alias-dry-run",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"clawType", "content", "msgType", "receiverOpenDingTalkId", "uuid"},
|
||||
},
|
||||
{
|
||||
name: "mail write folder id concept alias",
|
||||
tool: "update_mail_folder",
|
||||
canonicalArgs: []string{
|
||||
"--dry-run", "mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--id", "folder-1", "--name", "Fixture Folder",
|
||||
},
|
||||
aliasArgs: []string{
|
||||
"--dry-run", "mail", "folder", "update",
|
||||
"--email", "fixture@example.com", "--folder-id", "folder-1", "--name", "Fixture Folder",
|
||||
},
|
||||
wantCorrections: 1,
|
||||
wantArgKeys: []string{"email", "id", "name"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, canonical, canonicalAttempts, canonicalErr := executeParamAliasDryRunE2E(t, test.canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("canonical dry-run failed: %v", canonicalErr)
|
||||
}
|
||||
ctx, alias, aliasAttempts, aliasErr := executeParamAliasDryRunE2E(t, test.aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("alias dry-run failed: %v\ncontext=%#v", aliasErr, ctx)
|
||||
}
|
||||
|
||||
if ctx == nil || len(ctx.Corrections) != test.wantCorrections {
|
||||
t.Fatalf("alias dry-run corrections = %#v, want %d", ctx, test.wantCorrections)
|
||||
}
|
||||
if len(canonicalAttempts) != 0 || len(aliasAttempts) != 0 {
|
||||
t.Fatalf("dry-run reached command runner\ncanonical=%#v\nalias=%#v", canonicalAttempts, aliasAttempts)
|
||||
}
|
||||
for label, preview := range map[string]paramAliasDryRunPreview{"canonical": canonical, "alias": alias} {
|
||||
if !preview.DryRun || preview.Executed {
|
||||
t.Fatalf("%s preview execution state = %#v", label, preview)
|
||||
}
|
||||
if preview.Tool != test.tool {
|
||||
t.Fatalf("%s preview tool = %q, want %q", label, preview.Tool, test.tool)
|
||||
}
|
||||
keys := make([]string, 0, len(preview.Arguments))
|
||||
for key := range preview.Arguments {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if !reflect.DeepEqual(keys, test.wantArgKeys) {
|
||||
t.Fatalf("%s preview argument keys = %v, want %v", label, keys, test.wantArgKeys)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(alias, canonical) {
|
||||
t.Fatalf("dry-run previews differ\ncanonical=%#v\nalias=%#v", canonical, alias)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageParamAliasCanonicalConflictFailsBeforeRunE(t *testing.T) {
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
for _, args := range [][]string{
|
||||
{"calendar", "event", "list", "--date", "2026-03-10", "--start", "2026-03-11"},
|
||||
{"calendar", "event", "list", "--start", "2026-03-11", "--date", "2026-03-10"},
|
||||
} {
|
||||
root := NewRootCommand()
|
||||
root.SetArgs(args)
|
||||
originalCaller := helpers.GetCaller()
|
||||
helpers.InitDeps(caller)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, newPipelineEngine(), args)
|
||||
helpers.InitDeps(originalCaller)
|
||||
var conflict *pipeline.FlagConflictError
|
||||
if !stderrors.As(err, &conflict) {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v, want FlagConflictError (ctx=%#v)", args, err, ctx)
|
||||
}
|
||||
if conflict.Canonical != "start" || !reflect.DeepEqual(conflict.Spellings, []string{"date", "start"}) {
|
||||
t.Fatalf("conflict = %#v", conflict)
|
||||
}
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("conflicting argv reached RunE/tool dispatch: %#v", caller.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageAllReviewedParamAliasGuardsReachRuntimeContract(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
paths := make(map[string]bool)
|
||||
for _, concept := range concepts.Concepts {
|
||||
for _, path := range concept.Commands {
|
||||
paths[path] = true
|
||||
}
|
||||
}
|
||||
sourceGuards := make(map[string]pipeline.FlagProtection)
|
||||
for _, override := range concepts.Overrides {
|
||||
paths[override.CommandPath] = true
|
||||
for _, emitted := range override.Block {
|
||||
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionBlocked
|
||||
}
|
||||
for _, emitted := range override.Ambiguous {
|
||||
sourceGuards[override.CommandPath+"\x00"+cmdutil.Morph(emitted)] = pipeline.FlagProtectionAmbiguous
|
||||
}
|
||||
}
|
||||
orderedPaths := make([]string, 0, len(paths))
|
||||
for path := range paths {
|
||||
orderedPaths = append(orderedPaths, path)
|
||||
}
|
||||
sort.Strings(orderedPaths)
|
||||
|
||||
root := NewRootCommand()
|
||||
engine := newPipelineEngine()
|
||||
guardCounts := map[pipeline.FlagProtection]int{}
|
||||
testedGuards := make(map[string]pipeline.FlagProtection)
|
||||
for _, path := range orderedPaths {
|
||||
entry, ok := cli.LookupParamAlias(path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
leaf := resolveParamLeaf(root, path)
|
||||
if leaf == nil {
|
||||
t.Fatalf("generated guard path %q is not runnable", path)
|
||||
}
|
||||
|
||||
for _, protectionCase := range []struct {
|
||||
protection pipeline.FlagProtection
|
||||
emitted []string
|
||||
}{
|
||||
{protection: pipeline.FlagProtectionBlocked, emitted: entry.Blocked},
|
||||
{protection: pipeline.FlagProtectionAmbiguous, emitted: entry.Ambiguous},
|
||||
} {
|
||||
for _, emitted := range protectionCase.emitted {
|
||||
protectionCase := protectionCase
|
||||
emitted := emitted
|
||||
key := path + "\x00" + cmdutil.Morph(emitted)
|
||||
if previous, duplicate := testedGuards[key]; duplicate {
|
||||
t.Fatalf("generated guard %q/%q is classified twice: %s and %s", path, emitted, previous, protectionCase.protection)
|
||||
}
|
||||
testedGuards[key] = protectionCase.protection
|
||||
guardCounts[protectionCase.protection]++
|
||||
|
||||
t.Run(path+"/"+emitted, func(t *testing.T) {
|
||||
value := "FIXTURE_VALUE"
|
||||
pathArgs := strings.Fields(path)
|
||||
args := append(append([]string(nil), pathArgs...), "--"+emitted, value)
|
||||
ctx, runErr := pipeline.RunPreParseArgs(root, engine, args)
|
||||
if runErr != nil {
|
||||
t.Fatalf("RunPreParseArgs(%v) error = %v", args, runErr)
|
||||
}
|
||||
|
||||
morphed := cmdutil.Morph(emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != protectionCase.protection {
|
||||
t.Fatalf("guard protection = %#v, want %s for %q", ctx, protectionCase.protection, morphed)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, emitted, value)
|
||||
flagArgs := ctx.Args[len(pathArgs):]
|
||||
if parseErr := leaf.ParseFlags(flagArgs); parseErr == nil || !strings.Contains(parseErr.Error(), "unknown flag") {
|
||||
t.Fatalf("guarded Cobra ParseFlags(%v) error = %v, want unknown flag", flagArgs, parseErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for key, want := range sourceGuards {
|
||||
if got, ok := testedGuards[key]; !ok || got != want {
|
||||
t.Fatalf("reviewed source guard %q delivered as %s (present=%t), want %s", key, got, ok, want)
|
||||
}
|
||||
}
|
||||
if guardCounts[pipeline.FlagProtectionBlocked] == 0 || guardCounts[pipeline.FlagProtectionAmbiguous] == 0 {
|
||||
t.Fatalf("reviewed guard coverage is vacuous: blocked %d ambiguous %d", guardCounts[pipeline.FlagProtectionBlocked], guardCounts[pipeline.FlagProtectionAmbiguous])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageRepresentativeParamAliasGuardsReachFinalErrorsWithoutDispatch(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
emitted string
|
||||
protection pipeline.FlagProtection
|
||||
reason string
|
||||
}{
|
||||
{path: "chat message list-by-sender", emitted: "time", protection: pipeline.FlagProtectionBlocked, reason: "blocked_flag"},
|
||||
{path: "drive list", emitted: "space", protection: pipeline.FlagProtectionAmbiguous, reason: "ambiguous_flag"},
|
||||
} {
|
||||
test := test
|
||||
t.Run(test.path+"/"+test.emitted, func(t *testing.T) {
|
||||
value := "FIXTURE_VALUE"
|
||||
args := append(strings.Fields(test.path), "--"+test.emitted, value)
|
||||
caller := ¶mAliasCaptureCaller{}
|
||||
ctx, executeErr := executeParamAliasE2E(t, caller, args...)
|
||||
|
||||
morphed := cmdutil.Morph(test.emitted)
|
||||
if ctx == nil || ctx.ProtectedFlags[morphed] != test.protection {
|
||||
t.Fatalf("guard protection = %#v, want %s for %q", ctx, test.protection, morphed)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, test.emitted, value)
|
||||
|
||||
var appErr *apperrors.Error
|
||||
if !stderrors.As(executeErr, &appErr) {
|
||||
t.Fatalf("final error = %T %v, want *errors.Error", executeErr, executeErr)
|
||||
}
|
||||
if appErr.Category != apperrors.CategoryValidation || appErr.Reason != test.reason || apperrors.ExitCode(executeErr) != 3 {
|
||||
t.Fatalf("final error contract = category %q reason %q exit %d, want validation/%s/3", appErr.Category, appErr.Reason, apperrors.ExitCode(executeErr), test.reason)
|
||||
}
|
||||
if !strings.Contains(appErr.Message, "unknown flag: --"+test.emitted) || !strings.Contains(appErr.Message, "See 'dws "+test.path+" --help' for usage.") {
|
||||
t.Fatalf("final error message = %q", appErr.Message)
|
||||
}
|
||||
if !strings.Contains(appErr.Hint, "--"+test.emitted) || !strings.Contains(appErr.Hint, "--help") {
|
||||
t.Fatalf("final error hint = %q", appErr.Hint)
|
||||
}
|
||||
wantAction := "Run 'dws " + test.path + " --help' for valid flags"
|
||||
if !reflect.DeepEqual(appErr.Actions, []string{wantAction}) || len(appErr.AvailableFlags) == 0 || appErr.Cause == nil {
|
||||
t.Fatalf("final recovery fields = actions %v flags %v cause %v", appErr.Actions, appErr.AvailableFlags, appErr.Cause)
|
||||
}
|
||||
if len(caller.calls) != 0 {
|
||||
t.Fatalf("guarded flag reached RunE/tool dispatch: %#v", caller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageFlagConflictErrorFormattingIsDeterministic(t *testing.T) {
|
||||
err := (&pipeline.FlagConflictError{Command: "dws demo", Canonical: "start", Spellings: []string{"start", "date"}}).Error()
|
||||
want := `conflicting parameter spellings for --start on "dws demo": --date, --start; pass exactly one spelling`
|
||||
if err != want {
|
||||
t.Fatalf("FlagConflictError = %q, want %q", err, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// TestParamAliasFixtureThroughEmbeddedDeliveryPath is the ⑥ regression gate.
|
||||
// It reads the reviewed validation_fixture straight from the embedded concept
|
||||
// dictionary and asserts every reviewed bad case through the REAL delivery
|
||||
// path — not a generator unit test and not a reimplementation of the reduction
|
||||
// logic:
|
||||
//
|
||||
// - the runtime PreParse engine built by newPipelineEngine() (the exact
|
||||
// handler chain root.go installs, whose SemanticAliasHandler is wired to
|
||||
// cli.LookupParamAlias over the embedded generated table),
|
||||
// - one distribution-owned Cobra tree, reused because PreParse reads command
|
||||
// and flag metadata but does not parse or mutate individual flag values,
|
||||
// and
|
||||
// - the embedded cli.LookupParamAlias query used to prove that a
|
||||
// did-you-mean case is an intentional block/ambiguous guard rather than a
|
||||
// name that merely happens to be absent from the table.
|
||||
//
|
||||
// Fixture expect semantics (see spec §⑥):
|
||||
// - expect=<realFlag> : emitted must reduce to that canonical flag.
|
||||
// - expect=did-you-mean:blocked : block guard hit; never auto-rewritten.
|
||||
// - expect=did-you-mean:ambiguous: co-occurrence guard hit; never rewritten.
|
||||
func TestCrossPlatformCoverageParamAliasFixtureThroughEmbeddedDeliveryPath(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
if len(concepts.Fixture) == 0 {
|
||||
t.Fatal("validation_fixture declares no cases; ⑥ gate would be vacuous")
|
||||
}
|
||||
|
||||
// The exact runtime handler chain (alias → semantic → sticky →
|
||||
// paramname), with the semantic table sourced from the embedded generated
|
||||
// snapshot. Build the distribution-owned tree once: constructing the full
|
||||
// 800+ command tree for every fixture made the macOS race package exceed its
|
||||
// 10-minute budget, while RunPreParseArgs itself only reads this tree.
|
||||
engine := newPipelineEngine()
|
||||
root := NewSchemaSourceRootCommand()
|
||||
for _, c := range concepts.Fixture {
|
||||
t.Run(c.Command+"/"+c.Emitted, func(t *testing.T) {
|
||||
leaf := resolveParamLeaf(root, c.Command)
|
||||
if leaf == nil {
|
||||
t.Fatalf("fixture command %q is not a live Cobra command", c.Command)
|
||||
}
|
||||
// Fixture command paths carry no "dws" prefix; LookupParamAlias
|
||||
// normalizes to the same key the generator used, so the runtime
|
||||
// lookup is byte-identical to the build-time key.
|
||||
entry, hasEntry := cli.LookupParamAlias(c.Command)
|
||||
fixtureValue := paramFixtureValue(leaf, c.Emitted, c.Expect)
|
||||
rawArgs := append(strings.Fields(c.Command), "--"+c.Emitted, fixtureValue)
|
||||
root.SetArgs(rawArgs)
|
||||
ctx, err := pipeline.RunPreParseArgs(root, engine, rawArgs)
|
||||
if err != nil {
|
||||
t.Fatalf("RunPreParseArgs error = %v", err)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("RunPreParseArgs skipped a fixture command with real flags")
|
||||
}
|
||||
morphed := cmdutil.Morph(c.Emitted)
|
||||
|
||||
switch c.Expect {
|
||||
case "did-you-mean:ambiguous":
|
||||
if !hasEntry || !entry.IsAmbiguous(morphed) {
|
||||
t.Fatalf("%q on %q: expected co-occurrence guard (ambiguous) but embedded entry does not classify it; ambiguous=%v", c.Emitted, c.Command, entry.Ambiguous)
|
||||
}
|
||||
if commandHasRealFlagByMorph(leaf, morphed) {
|
||||
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
|
||||
case "did-you-mean:blocked":
|
||||
if !hasEntry || !entry.IsBlocked(morphed) {
|
||||
t.Fatalf("%q on %q: expected block guard but embedded entry does not classify it; blocked=%v", c.Emitted, c.Command, entry.Blocked)
|
||||
}
|
||||
if commandHasRealFlagByMorph(leaf, morphed) {
|
||||
t.Fatalf("guarded --%s on %q is a real Cobra flag and would bypass the unknown-flag recovery path", c.Emitted, c.Command)
|
||||
}
|
||||
assertLeftUnchanged(t, ctx, c.Emitted, fixtureValue)
|
||||
default:
|
||||
// Real-flag expect: the reviewed canonical outcome is delivered
|
||||
// one of two equally valid ways, and the gate accepts either
|
||||
// (failing only on a genuine unknown-flag hallucination):
|
||||
// 1. semantic rewrite — the emitted synonym is not a real flag,
|
||||
// so the embedded table rewrites it to the canonical flag; or
|
||||
// 2. native acceptance — the emitted synonym is still a genuine
|
||||
// (usually hidden) real flag the command accepts directly and
|
||||
// maps to the same entity via its fallback wiring. Native
|
||||
// compatibility flags intentionally remain command-owned.
|
||||
if !commandHasRealFlagByMorph(leaf, cmdutil.Morph(c.Expect)) {
|
||||
t.Fatalf("reviewed canonical --%s on %q is not a real Cobra flag", c.Expect, c.Command)
|
||||
}
|
||||
flagArgs := ctx.Args[len(strings.Fields(c.Command)):]
|
||||
if len(flagArgs) == 0 {
|
||||
t.Fatalf("%q on %q lost its flag and value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
got := flagArgs[0]
|
||||
gotBare, inlineValue, hasInlineValue := strings.Cut(strings.TrimPrefix(got, "--"), "=")
|
||||
switch {
|
||||
case hasInlineValue && inlineValue != fixtureValue:
|
||||
t.Fatalf("%q on %q changed its inline value: got %q, want %q (args=%v)", c.Emitted, c.Command, inlineValue, fixtureValue, ctx.Args)
|
||||
case !hasInlineValue && (len(flagArgs) < 2 || flagArgs[1] != fixtureValue):
|
||||
t.Fatalf("%q on %q lost its value: args=%v", c.Emitted, c.Command, ctx.Args)
|
||||
}
|
||||
switch {
|
||||
case gotBare == c.Expect:
|
||||
// (1) rewritten; the embedded table must agree.
|
||||
if !hasEntry {
|
||||
t.Fatalf("%q on %q was rewritten without an embedded alias entry", c.Emitted, c.Command)
|
||||
}
|
||||
if canon, hit := entry.ResolveAlias(morphed); !hit || canon != c.Expect {
|
||||
t.Fatalf("embedded table ResolveAlias(%q) on %q = %q (hit=%v), want %q", morphed, c.Command, canon, hit, c.Expect)
|
||||
}
|
||||
case cmdutil.Morph(gotBare) == morphed && commandHasRealFlagByMorph(leaf, morphed):
|
||||
// (2) not rewritten — only valid if the command natively
|
||||
// accepts the emitted synonym as a real flag.
|
||||
default:
|
||||
t.Fatalf("%q on %q reduced to unexpected %q, want --%s or native --%s (args=%v)", c.Emitted, c.Command, got, c.Expect, c.Emitted, ctx.Args)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertLeftUnchanged verifies a guarded (blocked/ambiguous) synonym is never
|
||||
// silently rewritten: the flag token and its value survive verbatim so the
|
||||
// unknown-flag did-you-mean path can surface the reviewed candidates.
|
||||
func assertLeftUnchanged(t *testing.T, ctx *pipeline.Context, emitted, value string) {
|
||||
t.Helper()
|
||||
flagIndex := -1
|
||||
for i, arg := range ctx.Args {
|
||||
if arg == "--"+emitted || strings.HasPrefix(arg, "--"+emitted+"=") {
|
||||
flagIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if flagIndex < 0 {
|
||||
t.Fatalf("guarded synonym --%s disappeared: args=%v", emitted, ctx.Args)
|
||||
}
|
||||
if got := ctx.Args[flagIndex]; got != "--"+emitted {
|
||||
t.Fatalf("guarded synonym --%s was rewritten to %q (must be left for did-you-mean): args=%v", emitted, got, ctx.Args)
|
||||
}
|
||||
if len(ctx.Args) <= flagIndex+1 || ctx.Args[flagIndex+1] != value {
|
||||
t.Fatalf("guarded synonym --%s lost its value: args=%v", emitted, ctx.Args)
|
||||
}
|
||||
for _, corr := range ctx.Corrections {
|
||||
if corr.Handler == "semantic-alias" && corr.Original == "--"+emitted {
|
||||
t.Fatalf("guarded synonym --%s was corrected by %s (must not be): %+v", emitted, corr.Handler, corr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paramFixtureValue(cmd *cobra.Command, emitted, expect string) string {
|
||||
if cmd == nil {
|
||||
return "FIXTURE_VALUE"
|
||||
}
|
||||
wanted := []string{emitted}
|
||||
if !strings.HasPrefix(expect, "did-you-mean:") {
|
||||
wanted = append(wanted, expect)
|
||||
}
|
||||
for _, name := range wanted {
|
||||
var found *pflag.Flag
|
||||
cmd.Flags().VisitAll(func(flag *pflag.Flag) {
|
||||
if found == nil && cmdutil.Morph(flag.Name) == cmdutil.Morph(name) {
|
||||
found = flag
|
||||
}
|
||||
})
|
||||
if found == nil {
|
||||
continue
|
||||
}
|
||||
switch found.Value.Type() {
|
||||
case "bool":
|
||||
return "true"
|
||||
case "int", "int8", "int16", "int32", "int64", "uint", "uint8", "uint16", "uint32", "uint64", "float32", "float64":
|
||||
return "1"
|
||||
}
|
||||
}
|
||||
return "FIXTURE_VALUE"
|
||||
}
|
||||
|
||||
// resolveParamLeaf resolves a fixture command path (no "dws" prefix, e.g.
|
||||
// "chat message search-advanced") to its live Cobra command, or nil.
|
||||
func resolveParamLeaf(root *cobra.Command, path string) *cobra.Command {
|
||||
cmd, _, err := root.Find(strings.Fields(path))
|
||||
if err != nil || cmd == nil || cmd == root {
|
||||
return nil
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
// commandHasRealFlagByMorph reports whether the command has any real flag
|
||||
// (local or inherited, including hidden) whose Morph matches morphed — the same
|
||||
// notion of "real flag" the build-time reducer uses to absorb legacy synonyms.
|
||||
func commandHasRealFlagByMorph(cmd *cobra.Command, morphed string) bool {
|
||||
found := false
|
||||
check := func(f *pflag.Flag) {
|
||||
if f.Name != "help" && cmdutil.Morph(f.Name) == morphed {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
cmd.Flags().VisitAll(check)
|
||||
cmd.InheritedFlags().VisitAll(check)
|
||||
return found
|
||||
}
|
||||
@@ -0,0 +1,485 @@
|
||||
// Copyright 2026 Alibaba Group
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
|
||||
package app
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// paramAliasCompleteCommands is deliberately keyed by the exact reviewed
|
||||
// fixture command path. Every argv is a complete, business-valid invocation:
|
||||
// required companion flags are present, time and enum values are valid, and
|
||||
// write commands use the capture caller rather than a real transport. The
|
||||
// target canonical flag must occur exactly once so the test can replace only
|
||||
// its spelling while holding every other input constant.
|
||||
var paramAliasCompleteCommands = map[string][]string{
|
||||
"aitable +base-search": {"aitable", "+base-search", "--query", "fixture"},
|
||||
"aitable +field-get": {"aitable", "+field-get", "--base-id", "base-1", "--table-id", "table-1"},
|
||||
"aitable +list-tables": {"aitable", "+list-tables", "--base", "base-1"},
|
||||
"aitable +record-query": {"aitable", "+record-query", "--base-id", "base-1", "--table-id", "table-1", "--query", "fixture"},
|
||||
"aitable +record-share-url": {"aitable", "+record-share-url", "--base-id", "base-1", "--table-id", "table-1", "--record-ids", "record-1"},
|
||||
"aitable +table-get": {"aitable", "+table-get", "--base-id", "base-1"},
|
||||
"aitable record query": {"aitable", "record", "query", "--base-id", "base-1", "--table-id", "table-1", "--limit", "7"},
|
||||
"attendance check result": {"attendance", "check", "result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"attendance +check-result": {"attendance", "+check-result", "--users", "user-1,user-2", "--start", "2026-03-01", "--end", "2026-03-02"},
|
||||
"calendar event list": {"calendar", "event", "list", "--start", "2026-03-10T14:00:00+08:00", "--end", "2026-03-10T18:00:00+08:00", "--calendar-id", "primary", "--cursor", "cursor-1", "--limit", "7"},
|
||||
"chat +chat-messages": {"chat", "+chat-messages", "--group", "fixture-conversation"},
|
||||
"chat +chat-add-bot": {"chat", "+chat-add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat +chat-audit-join": {"chat", "+chat-audit-join", "--group", "fixture-conversation", "--record-id", "7", "--applicant", "user-1", "--inviter", "user-2", "--status", "AuditApprove", "--yes"},
|
||||
"chat +chat-members-get": {"chat", "+chat-members-get", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat +chat-members-list": {"chat", "+chat-members-list", "--conversation-id", "fixture-conversation", "--member-types", "user,bot"},
|
||||
"chat +chat-mute-member": {"chat", "+chat-mute-member", "--group", "fixture-conversation", "--users", "D-user-1,D-user-2", "--mute-time", "3600000", "--yes"},
|
||||
"chat +chat-remove-bot": {"chat", "+chat-remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat +chat-role-remove-user": {"chat", "+chat-role-remove-user", "--group", "fixture-conversation", "--user", "D-user-1", "--role-ids", "role-1", "--yes"},
|
||||
"chat +chat-transfer-owner": {"chat", "+chat-transfer-owner", "--group", "fixture-conversation", "--new-owner", "D-user-1", "--yes"},
|
||||
"chat +chat-update": {"chat", "+chat-update", "--group", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat +bot-find": {"chat", "+bot-find", "--query", "fixture", "--limit", "7"},
|
||||
"chat +bot-search": {"chat", "+bot-search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat +category-create": {"chat", "+category-create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat +category-rename": {"chat", "+category-rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat +group-members": {"chat", "+group-members", "--group", "Fixture Group"},
|
||||
"chat +conversation-set-top": {"chat", "+conversation-set-top", "--conversation-id", "fixture-conversation", "--yes"},
|
||||
"chat +feed-group-query-item": {"chat", "+feed-group-query-item", "--category-id", "7", "--conversation-ids", "fixture-conversation"},
|
||||
"chat +flag-cancel": {"chat", "+flag-cancel", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-create": {"chat", "+flag-create", "--conversation-id", "fixture-conversation", "--message-id", "message-1", "--yes"},
|
||||
"chat +flag-list": {"chat", "+flag-list", "--cursor", "0", "--page-size", "7"},
|
||||
"chat +messages-combine-forward": {"chat", "+messages-combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward": {"chat", "+messages-forward", "--src-conversation-id", "fixture-source", "--msg-id", "message-1", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-forward-topic": {"chat", "+messages-forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat +messages-list": {"chat", "+messages-list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-direct": {"chat", "+messages-list-direct", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat +messages-list-unread-conversations": {"chat", "+messages-list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat +messages-reply": {"chat", "+messages-reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat +messages-resource-download": {"chat", "+messages-resource-download", "--resource-id", "resource-1", "--message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--output", "downloads/fixture.bin"},
|
||||
"chat +messages-set-pin": {"chat", "+messages-set-pin", "--open-conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat +messages-send-by-webhook": {"chat", "+messages-send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"chat +search-msg": {"chat", "+search-msg", "--group", "fixture-conversation", "--query", "fixture", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--no-enrich"},
|
||||
"chat +send-to-group": {"chat", "+send-to-group", "--group", "Fixture Group", "--text", "hello fixture", "--yes"},
|
||||
"chat +unread-chats": {"chat", "+unread-chats", "--count", "7", "--exclude-muted"},
|
||||
"chat bot find": {"chat", "bot", "find", "--query", "fixture", "--limit", "7"},
|
||||
"chat bot search": {"chat", "bot", "search", "--name", "Fixture Bot", "--page", "2", "--size", "7"},
|
||||
"chat category create": {"chat", "category", "create", "--title", "Fixture Cat", "--yes"},
|
||||
"chat category create-smart": {"chat", "category", "create-smart", "--name", "Fixture Smart Category", "--keywords", "fixture,priority", "--yes"},
|
||||
"chat category rename": {"chat", "category", "rename", "--category-id", "7", "--title", "Renamed Cat", "--yes"},
|
||||
"chat group members": {"chat", "group", "members", "--id", "fixture-conversation"},
|
||||
"chat group members add": {"chat", "group", "members", "add", "--id", "fixture-conversation", "--users", "D-user-1"},
|
||||
"chat group members add-bot": {"chat", "group", "members", "add-bot", "--id", "fixture-conversation", "--robot-code", "robot-1", "--yes"},
|
||||
"chat group members list-by-ids": {"chat", "group", "members", "list-by-ids", "--id", "fixture-conversation", "--users", "D-user-1,D-user-2"},
|
||||
"chat group members remove": {"chat", "group", "members", "remove", "--id", "fixture-conversation", "--users", "D-user-1", "--yes"},
|
||||
"chat group members remove-bot": {"chat", "group", "members", "remove-bot", "--id", "fixture-conversation", "--bot-id", "bot-1", "--yes"},
|
||||
"chat group rename": {"chat", "group", "rename", "--id", "fixture-conversation", "--name", "Fixture Renamed Group", "--yes"},
|
||||
"chat group set-admin": {"chat", "group", "set-admin", "--group", "fixture-conversation", "--user", "user-1", "--yes"},
|
||||
"chat message add-emoji": {"chat", "message", "add-emoji", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--emoji", "赞", "--yes"},
|
||||
"chat message add-favorite": {"chat", "message", "add-favorite", "--open-message-id", "message-1", "--open-conversation-id", "fixture-conversation", "--yes"},
|
||||
"chat message combine-forward": {"chat", "message", "combine-forward", "--src-conversation-id", "fixture-source", "--msg-ids", "message-1,message-2", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat message forward-topic": {"chat", "message", "forward-topic", "--src-msg-id", "message-1", "--src-conversation-id", "fixture-source", "--src-thread-id", "convThread-fixture", "--dest-conversation-id", "fixture-destination", "--yes"},
|
||||
"chat message list": {"chat", "message", "list", "--group", "fixture-conversation", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
"chat message list-all": {"chat", "message", "list-all", "--start", "2026-03-10 00:00:00", "--end", "2026-03-11 00:00:00"},
|
||||
"chat message list-by-sender": {"chat", "message", "list-by-sender", "--sender-user-id", "user-1", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
"chat message list-favorites": {"chat", "message", "list-favorites", "--cursor", "2", "--size", "7"},
|
||||
"chat message list-by-ids": {"chat", "message", "list-by-ids", "--msg-ids", "message-1,message-2"},
|
||||
"chat message list-unread-conversations": {"chat", "message", "list-unread-conversations", "--count", "7", "--exclude-muted"},
|
||||
"chat message recall": {"chat", "message", "recall", "--conversation-id", "fixture-conversation", "--msg-id", "message-1", "--yes"},
|
||||
"chat message reply": {"chat", "message", "reply", "--conversation-id", "fixture-conversation", "--ref-msg-id", "message-1", "--ref-sender", "D-sender", "--text", "hello fixture", "--yes"},
|
||||
"chat message search-advanced": {"chat", "message", "search-advanced", "--conversation-ids", "fixture-conversation", "--query", "fixture"},
|
||||
"chat message send": {"chat", "message", "send", "--user", "D-recipient", "--text", "hello fixture", "--uuid", "param-alias-equivalence", "--yes"},
|
||||
"chat message send-by-bot": {"chat", "message", "send-by-bot", "--robot-code", "robot-1", "--group", "fixture-conversation", "--title", "Fixture Alert", "--text", "@user-1 @user-2 fixture", "--at-user-ids", "user-1,user-2", "--yes"},
|
||||
"chat message send-by-webhook": {"chat", "message", "send-by-webhook", "--token", "fixture-token", "--title", "Fixture Alert", "--text", "fixture", "--at-users", "user-1,user-2", "--yes"},
|
||||
"contact +dept-members": {"contact", "+dept-members", "--dept", "Fixture Dept"},
|
||||
"contact +list-sub-depts": {"contact", "+list-sub-depts", "--dept", "1"},
|
||||
"contact +resolve-dept": {"contact", "+resolve-dept", "--name", "Fixture Dept"},
|
||||
"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"},
|
||||
"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"},
|
||||
"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"},
|
||||
"doc +access-grant": {"doc", "+access-grant", "--node", "node-1", "--to", "Fixture User", "--role", "READER", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +copy": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
"doc +create": {"doc", "+create", "--name", "Fixture Document", "--content", "fixture body", "--doc-format", "markdown"},
|
||||
"doc +create-from-template": {"doc", "+create-from-template", "--query", "fixture template", "--name", "Fixture From Template", "--folder", "folder-1", "--workspace", "workspace-1"},
|
||||
"doc +doc-append": {"doc", "+doc-append", "--doc", "node-1", "--text", "fixture appendix", "--yes"},
|
||||
"doc +export-submit": {"doc", "+export-submit", "--node", "node-1", "--export-format", "docx"},
|
||||
"doc +fetch": {"doc", "+fetch", "--node", "node-1", "--scope", "section", "--start-block-id", "block-1"},
|
||||
"doc +find-doc": {"doc", "+find-doc", "--query", "fixture", "--limit", "7"},
|
||||
"doc +history-revert": {"doc", "+history-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +inspect": {"doc", "+inspect", "--node", "node-1", "--include-history"},
|
||||
"doc +list": {"doc", "+list", "--folder", "folder-1", "--cursor", "cursor-1"},
|
||||
"doc +move": {"doc", "+move", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"doc +search": {"doc", "+search", "--query", "fixture", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-list": {"doc", "+template-list", "--source", "MY", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +template-search": {"doc", "+template-search", "--query", "fixture", "--source", "MY", "--limit", "7"},
|
||||
"doc +version-list": {"doc", "+version-list", "--node", "node-1", "--limit", "7", "--cursor", "cursor-1"},
|
||||
"doc +version-revert": {"doc", "+version-revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"doc +version-save": {"doc", "+version-save", "--node", "node-1", "--yes"},
|
||||
"doc +update": {"doc", "+update", "--node", "node-1", "--command", "overwrite", "--content", `["root",{}]`, "--doc-format", "jsonml", "--expected-revision", "1", "--yes"},
|
||||
"doc block insert": {"doc", "block", "insert", "--node", "node-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc block update": {"doc", "block", "update", "--node", "node-1", "--block-id", "block-1", "--text", "fixture paragraph", "--yes"},
|
||||
"doc comment create": {"doc", "comment", "create", "--node", "node-1", "--content", "fixture comment", "--yes"},
|
||||
"doc comment create-inline": {"doc", "comment", "create-inline", "--node", "node-1", "--block-id", "block-1", "--start", "0", "--end", "7", "--content", "fixture comment", "--yes"},
|
||||
"doc comment delete": {"doc", "comment", "delete", "--node", "node-1", "--comment-key", "comment-1", "--yes"},
|
||||
"doc comment reply": {"doc", "comment", "reply", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture reply", "--mentioned-open-conversation-id", "cid-1,cid-2", "--yes"},
|
||||
"doc comment update": {"doc", "comment", "update", "--node", "node-1", "--comment-key", "comment-1", "--content", "fixture update", "--yes"},
|
||||
"doc version revert": {"doc", "version", "revert", "--node", "node-1", "--version", "3", "--yes"},
|
||||
"drive info": {"drive", "info", "--node", "node-1", "--space-id", "space-1"},
|
||||
"drive list": {"drive", "list", "--folder", "folder-1", "--limit", "7"},
|
||||
"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 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"},
|
||||
}
|
||||
|
||||
// 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{
|
||||
"doc +copy": {
|
||||
"folder": {"doc", "+copy", "--node", "node-1", "--folder", "folder-1", "--yes"},
|
||||
"workspace": {"doc", "+copy", "--node", "node-1", "--workspace", "workspace-1", "--yes"},
|
||||
},
|
||||
"doc block insert": {
|
||||
"parent-block": {"doc", "block", "insert", "--node", "node-1", "--parent-block", "parent-block-1", "--index", "0", "--text", "fixture paragraph", "--yes"},
|
||||
},
|
||||
"chat message list": {
|
||||
"user": {"chat", "message", "list", "--user", "user-1", "--time", "2026-03-10 00:00:00", "--limit", "7"},
|
||||
},
|
||||
"chat message list-by-sender": {
|
||||
"sender-open-dingtalk-id": {"chat", "message", "list-by-sender", "--sender-open-dingtalk-id", "D-sender", "--start", "2026-03-10T00:00:00+08:00", "--end", "2026-03-11T00:00:00+08:00", "--limit", "7", "--cursor", "0"},
|
||||
},
|
||||
"chat message send": {
|
||||
"group": {"chat", "message", "send", "--group", "fixture-conversation", "--text", "hello fixture", "--uuid", "param-alias-equivalence-group", "--yes"},
|
||||
"file-path": {"chat", "message", "send", "--group", "fixture-conversation", "--msg-type", "file", "--file-path", "../../go.mod", "--dentry-id", "1", "--space-id", "2", "--uuid", "param-alias-equivalence-file", "--yes"},
|
||||
},
|
||||
"chat +conversation-set-top": {
|
||||
"conversation-ids": {"chat", "+conversation-set-top", "--conversation-ids", "fixture-conversation-1,fixture-conversation-2", "--yes"},
|
||||
},
|
||||
}
|
||||
|
||||
// paramAliasNewIMCases is the exact set of aliases added by the reviewed IM
|
||||
// optimization. The dedicated gate below requires every one to remain active
|
||||
// in the embedded generated table and equivalent at the final transport.
|
||||
var paramAliasNewIMCases = []struct {
|
||||
command string
|
||||
emitted string
|
||||
canonical string
|
||||
}{
|
||||
{command: "chat +chat-messages", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +bot-find", emitted: "name", canonical: "query"},
|
||||
{command: "chat bot find", emitted: "name", canonical: "query"},
|
||||
{command: "chat +bot-search", emitted: "query", canonical: "name"},
|
||||
{command: "chat +bot-search", emitted: "current-page", canonical: "page"},
|
||||
{command: "chat +category-create", emitted: "name", canonical: "title"},
|
||||
{command: "chat +category-rename", emitted: "name", canonical: "title"},
|
||||
{command: "chat +messages-list-direct", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +messages-list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat +messages-send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +search-msg", emitted: "chat", canonical: "group"},
|
||||
{command: "chat +unread-chats", emitted: "limit", canonical: "count"},
|
||||
{command: "chat +unread-chats", emitted: "size", canonical: "count"},
|
||||
{command: "chat bot search", emitted: "query", canonical: "name"},
|
||||
{command: "chat bot search", emitted: "current-page", canonical: "page"},
|
||||
{command: "chat category create", emitted: "name", canonical: "title"},
|
||||
{command: "chat category create-smart", emitted: "title", canonical: "name"},
|
||||
{command: "chat category rename", emitted: "name", canonical: "title"},
|
||||
{command: "chat message list", emitted: "start", canonical: "time"},
|
||||
{command: "chat message list-by-sender", emitted: "user-id", canonical: "sender-user-id"},
|
||||
{command: "chat message list-by-sender", emitted: "open-dingtalk-id", canonical: "sender-open-dingtalk-id"},
|
||||
{command: "chat message list-favorites", emitted: "limit", canonical: "size"},
|
||||
{command: "chat message list-unread-conversations", emitted: "limit", canonical: "count"},
|
||||
{command: "chat message list-unread-conversations", emitted: "size", canonical: "count"},
|
||||
{command: "chat message send", emitted: "file", canonical: "file-path"},
|
||||
{command: "chat message send-by-bot", emitted: "at-users", canonical: "at-user-ids"},
|
||||
{command: "chat message send-by-webhook", emitted: "at-user-ids", canonical: "at-users"},
|
||||
{command: "chat +chat-update", emitted: "chat-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "open-conversation-id", canonical: "group"},
|
||||
{command: "chat +chat-update", emitted: "title", canonical: "name"},
|
||||
{command: "chat +chat-update", emitted: "new-title", canonical: "name"},
|
||||
{command: "chat +flag-list", emitted: "limit", canonical: "page-size"},
|
||||
{command: "chat +chat-members-list", emitted: "chat-id", canonical: "conversation-id"},
|
||||
{command: "chat +chat-members-list", emitted: "id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "open-conversation-id", canonical: "conversation-id"},
|
||||
{command: "chat +conversation-set-top", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +chat-members-get", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-members-get", emitted: "open-dingtalk-ids", canonical: "users"},
|
||||
{command: "chat +chat-members-get", emitted: "chat", canonical: "id"},
|
||||
{command: "chat +messages-list", emitted: "start", canonical: "time"},
|
||||
{command: "chat +messages-reply", emitted: "msg-id", canonical: "ref-msg-id"},
|
||||
{command: "chat +messages-reply", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +flag-cancel", emitted: "chat", canonical: "conversation-id"},
|
||||
{command: "chat +flag-create", emitted: "group", canonical: "conversation-id"},
|
||||
{command: "chat +chat-add-bot", emitted: "conversation-id", canonical: "id"},
|
||||
{command: "chat +chat-add-bot", emitted: "robot", canonical: "robot-code"},
|
||||
{command: "chat +chat-audit-join", emitted: "applicant-user-id", canonical: "applicant"},
|
||||
{command: "chat +chat-mute-member", emitted: "user-ids", canonical: "users"},
|
||||
{command: "chat +chat-remove-bot", emitted: "open-bot-id", canonical: "bot-id"},
|
||||
{command: "chat +chat-role-remove-user", emitted: "open-dingtalk-id", canonical: "user"},
|
||||
{command: "chat +chat-transfer-owner", emitted: "user-id", canonical: "new-owner"},
|
||||
{command: "chat +feed-group-query-item", emitted: "chat-ids", canonical: "conversation-ids"},
|
||||
{command: "chat +messages-combine-forward", emitted: "src-open-cid", canonical: "src-conversation-id"},
|
||||
{command: "chat +messages-forward", emitted: "source-message-id", canonical: "msg-id"},
|
||||
{command: "chat +messages-forward-topic", emitted: "src-open-message-id", canonical: "src-msg-id"},
|
||||
{command: "chat +messages-resource-download", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
{command: "chat +messages-set-pin", emitted: "conversation-id", canonical: "open-conversation-id"},
|
||||
}
|
||||
|
||||
// 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
|
||||
// checked through the embedded PreParse delivery path and against a complete
|
||||
// business-valid command template. The separate IM gate below continues to
|
||||
// execute every alias introduced by the current IM optimization.
|
||||
//
|
||||
// Keeping the older 100+ aliases at the contract layer avoids rebuilding and
|
||||
// executing the complete 800+ command Root twice per spelling under -race.
|
||||
// That duplicated command construction was enough to push the pre-existing
|
||||
// macOS app suite beyond its package-level 10-minute timeout.
|
||||
var paramAliasRepresentativePayloadCases = map[string]bool{
|
||||
paramAliasPayloadCaseKey("aitable +record-query", "base"): true, // concept alias on a shortcut read
|
||||
paramAliasPayloadCaseKey("attendance check result", "user-ids"): true, // list-valued concept alias
|
||||
paramAliasPayloadCaseKey("calendar event list", "date"): true, // time concept alias
|
||||
paramAliasPayloadCaseKey("chat message add-favorite", "msg-id"): true, // scoped IM identifier alias
|
||||
paramAliasPayloadCaseKey("contact user profile get", "user-id"): true, // native compatibility flag
|
||||
paramAliasPayloadCaseKey("devdoc article search", "current-page"): true, // command override
|
||||
paramAliasPayloadCaseKey("doc +comment-create", "body"): true, // write shortcut content alias
|
||||
paramAliasPayloadCaseKey("doc +copy", "parent-folder-id"): true, // Doc folder role on a write shortcut
|
||||
paramAliasPayloadCaseKey("doc +create", "content-format"): true, // shortcut format alias preserves markdown/jsonml enum
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "keyword"): true, // template search alias composes with a write workflow
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "workspace-id"): true, // template target workspace identifier
|
||||
paramAliasPayloadCaseKey("doc +create-from-template", "parent-folder-id"): true, // template target folder identifier
|
||||
paramAliasPayloadCaseKey("doc +export-submit", "doc-id"): true, // Doc node identifier alias
|
||||
paramAliasPayloadCaseKey("doc +fetch", "start-block"): true, // section boundary role remains exact
|
||||
paramAliasPayloadCaseKey("doc +history-revert", "version-number"): true, // destructive history version alias keeps confirmation
|
||||
paramAliasPayloadCaseKey("doc +inspect", "include-versions"): true, // boolean section alias preserves value
|
||||
paramAliasPayloadCaseKey("doc +template-list", "next-token"): true, // Doc cursor alias on a read shortcut
|
||||
paramAliasPayloadCaseKey("doc +update", "mode"): true, // write operation selector alias
|
||||
paramAliasPayloadCaseKey("doc +update", "revision"): true, // optimistic edit revision alias
|
||||
paramAliasPayloadCaseKey("doc +access-grant", "doc-id"): true, // permission write keeps document identity
|
||||
paramAliasPayloadCaseKey("doc +version-revert", "version-number"): true, // high-write version role with canonical confirmation
|
||||
paramAliasPayloadCaseKey("doc block insert", "content"): true, // block write content alias
|
||||
paramAliasPayloadCaseKey("doc block insert", "parent-block-id"): true, // scoped block-role alias
|
||||
paramAliasPayloadCaseKey("doc comment delete", "comment-id"): true, // destructive comment-key alias
|
||||
paramAliasPayloadCaseKey("doc comment reply", "mentioned-open-conversation-ids"): true, // list-valued group mention role
|
||||
paramAliasPayloadCaseKey("mail folder update", "folder-id"): true, // write-command identifier alias
|
||||
paramAliasPayloadCaseKey("report list", "from-date"): true, // date-range concept alias
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageReviewedParamAliasesHaveCompleteTemplatesAndRepresentativeFinalPayloads(t *testing.T) {
|
||||
concepts, err := cli.LoadParamConcepts()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadParamConcepts() error = %v", err)
|
||||
}
|
||||
|
||||
activeCommands := make(map[string]bool)
|
||||
activeCases := 0
|
||||
executedRepresentatives := make(map[string]bool)
|
||||
for _, fixture := range concepts.Fixture {
|
||||
if strings.HasPrefix(fixture.Expect, "did-you-mean:") {
|
||||
continue
|
||||
}
|
||||
activeCommands[fixture.Command] = true
|
||||
activeCases++
|
||||
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)
|
||||
continue
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, fixture.Expect, fixture.Emitted)
|
||||
if replacements != 1 {
|
||||
t.Errorf("complete command for %q/%q must contain canonical --%s exactly once; replacements=%d args=%v", fixture.Command, fixture.Emitted, fixture.Expect, replacements, canonicalArgs)
|
||||
continue
|
||||
}
|
||||
|
||||
caseKey := paramAliasPayloadCaseKey(fixture.Command, fixture.Emitted)
|
||||
if !paramAliasRepresentativePayloadCases[caseKey] {
|
||||
continue
|
||||
}
|
||||
executedRepresentatives[caseKey] = true
|
||||
t.Run(fixture.Command+"/"+fixture.Emitted, func(t *testing.T) {
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(fixture.Command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if activeCases == 0 {
|
||||
t.Fatal("reviewed fixture contains no active alias cases")
|
||||
}
|
||||
for command := range paramAliasCompleteCommands {
|
||||
if !activeCommands[command] {
|
||||
t.Errorf("complete-command E2E template %q has no active reviewed fixture", command)
|
||||
}
|
||||
}
|
||||
for command := range activeCommands {
|
||||
if _, ok := paramAliasCompleteCommands[command]; !ok {
|
||||
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)
|
||||
}
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func TestCrossPlatformCoverageNewIMParamAliasesReachCanonicalEquivalentFinalPayloads(t *testing.T) {
|
||||
activeAliases := 0
|
||||
for _, test := range paramAliasNewIMCases {
|
||||
test := test
|
||||
t.Run(test.command+"/"+test.emitted, func(t *testing.T) {
|
||||
complete, ok := paramAliasCompleteCommand(test.command, test.canonical)
|
||||
if !ok {
|
||||
t.Fatal("reviewed IM alias has no complete-command E2E template")
|
||||
}
|
||||
canonicalArgs := append([]string(nil), complete...)
|
||||
aliasArgs, replacements := replaceLongFlag(canonicalArgs, test.canonical, test.emitted)
|
||||
if replacements != 1 {
|
||||
t.Fatalf("complete command must contain canonical --%s exactly once; replacements=%d args=%v", test.canonical, replacements, canonicalArgs)
|
||||
}
|
||||
|
||||
canonicalCaller := ¶mAliasCaptureCaller{}
|
||||
_, canonicalErr := executeParamAliasPayloadE2E(t, canonicalCaller, canonicalArgs...)
|
||||
if canonicalErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, canonicalErr) {
|
||||
t.Fatalf("complete canonical command failed: %v\nargs=%v\ncalls=%#v", canonicalErr, canonicalArgs, canonicalCaller.calls)
|
||||
}
|
||||
if len(canonicalCaller.calls) == 0 {
|
||||
t.Fatalf("complete canonical command reached no final transport payload: args=%v", canonicalArgs)
|
||||
}
|
||||
|
||||
entry, exists := cli.LookupParamAlias(test.command)
|
||||
target, active := entry.ResolveAlias(test.emitted)
|
||||
if !exists || !active {
|
||||
return
|
||||
}
|
||||
if target != test.canonical {
|
||||
t.Fatalf("active reviewed IM alias --%s resolves to --%s, want --%s", test.emitted, target, test.canonical)
|
||||
}
|
||||
activeAliases++
|
||||
aliasCaller := ¶mAliasCaptureCaller{}
|
||||
ctx, aliasErr := executeParamAliasPayloadE2E(t, aliasCaller, aliasArgs...)
|
||||
if aliasErr != nil && !paramAliasExpectedCaptureBoundaryError(test.command, aliasErr) {
|
||||
t.Fatalf("complete alias command failed: %v\nargs=%v\ncalls=%#v", aliasErr, aliasArgs, aliasCaller.calls)
|
||||
}
|
||||
if ctx == nil {
|
||||
t.Fatal("complete alias command skipped PreParse")
|
||||
}
|
||||
if (canonicalErr == nil) != (aliasErr == nil) || (canonicalErr != nil && canonicalErr.Error() != aliasErr.Error()) {
|
||||
t.Fatalf("canonical and alias completion errors differ: canonical=%v alias=%v", canonicalErr, aliasErr)
|
||||
}
|
||||
normalizeParamAliasVolatileDefaults(test.command, canonicalCaller, aliasCaller)
|
||||
if !reflect.DeepEqual(aliasCaller.calls, canonicalCaller.calls) {
|
||||
t.Fatalf("final transport calls differ\ncanonical args: %v\nalias args: %v\ncanonical calls: %#v\nalias calls: %#v", canonicalArgs, aliasArgs, canonicalCaller.calls, aliasCaller.calls)
|
||||
}
|
||||
})
|
||||
}
|
||||
if activeAliases != len(paramAliasNewIMCases) {
|
||||
t.Fatalf("new IM aliases active in embedded table = %d, want %d", activeAliases, len(paramAliasNewIMCases))
|
||||
}
|
||||
}
|
||||
|
||||
// Resource download deliberately continues from the transport call into a
|
||||
// local HTTPS download. The generic capture caller returns an empty object, so
|
||||
// this command's stable post-transport validation error is the expected test
|
||||
// boundary; canonical and alias calls must still produce the same request and
|
||||
// the same error.
|
||||
func paramAliasExpectedCaptureBoundaryError(command string, err error) bool {
|
||||
return command == "chat +messages-resource-download" && err != nil &&
|
||||
strings.Contains(err.Error(), "资源下载接口未返回合法的 HTTPS 下载地址")
|
||||
}
|
||||
|
||||
// +chat-messages supplies the current wall-clock time when callers omit
|
||||
// --time. Alias equivalence concerns the resolved target and transport shape;
|
||||
// a suite crossing a second boundary must not make that default appear
|
||||
// alias-dependent.
|
||||
func normalizeParamAliasVolatileDefaults(command string, callers ...*paramAliasCaptureCaller) {
|
||||
if command != "chat +chat-messages" {
|
||||
return
|
||||
}
|
||||
for _, caller := range callers {
|
||||
for i := range caller.calls {
|
||||
if caller.calls[i].tool == "list_conversation_message_v2" || caller.calls[i].tool == "list_individual_chat_message" {
|
||||
delete(caller.calls[i].args, "time")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paramAliasCompleteCommand(command, canonical string) ([]string, bool) {
|
||||
complete, ok := paramAliasCompleteCommands[command]
|
||||
if variants := paramAliasCompleteCommandVariants[command]; variants != nil {
|
||||
if variant, exists := variants[canonical]; exists {
|
||||
return variant, true
|
||||
}
|
||||
}
|
||||
return complete, ok
|
||||
}
|
||||
|
||||
func paramAliasPayloadCaseKey(command, emitted string) string {
|
||||
return command + "\x00" + emitted
|
||||
}
|
||||
|
||||
func executeParamAliasPayloadE2E(t *testing.T, caller *paramAliasCaptureCaller, args ...string) (*pipeline.Context, error) {
|
||||
t.Helper()
|
||||
return executeParamAliasE2E(t, caller, args...)
|
||||
}
|
||||
|
||||
func replaceLongFlag(args []string, canonical, emitted string) ([]string, int) {
|
||||
out := append([]string(nil), args...)
|
||||
replacements := 0
|
||||
for index, arg := range out {
|
||||
if arg == "--"+canonical {
|
||||
out[index] = "--" + emitted
|
||||
replacements++
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(arg, "--"+canonical+"=") {
|
||||
out[index] = "--" + emitted + strings.TrimPrefix(arg, "--"+canonical)
|
||||
replacements++
|
||||
}
|
||||
}
|
||||
return out, replacements
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
// 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 app
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline"
|
||||
)
|
||||
|
||||
// TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist locks the
|
||||
// boundary between the command's original hidden compatibility flags and the
|
||||
// new central semantic normalizer. Existing real flags stay untouched and are
|
||||
// handled by calendar.go's flagOrFallback chain; only spellings that are not
|
||||
// real flags (for example --date, --from, and --since) are rewritten centrally.
|
||||
func TestCalendarEventListNativeFallbacksAndCentralAliasesCoexist(t *testing.T) {
|
||||
engine := newPipelineEngine()
|
||||
|
||||
cases := []struct {
|
||||
emitted string
|
||||
value string
|
||||
canonical string
|
||||
isInt bool
|
||||
native bool
|
||||
}{
|
||||
// Existing Calendar compatibility flags remain native.
|
||||
{"start-time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"startTime", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"start_time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"start-date", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"min-time", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"time-min", "2026-03-10T14:00:00+08:00", "start", false, true},
|
||||
{"end-time", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"endTime", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"end-date", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"max-time", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"time-max", "2026-03-10T18:00:00+08:00", "end", false, true},
|
||||
{"max-results", "50", "limit", true, true},
|
||||
{"maxResults", "50", "limit", true, true},
|
||||
{"page-size", "50", "limit", true, true},
|
||||
{"size", "50", "limit", true, true},
|
||||
{"next-cursor", "TOKEN123", "cursor", false, true},
|
||||
{"nextCursor", "TOKEN123", "cursor", false, true},
|
||||
{"page-token", "TOKEN123", "cursor", false, true},
|
||||
{"next-token", "TOKEN123", "cursor", false, true},
|
||||
{"calendar", "primary", "calendar-id", false, true},
|
||||
{"calendarId", "primary", "calendar-id", false, true},
|
||||
// These spellings have no native Calendar flag and remain central aliases.
|
||||
{"from", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
{"since", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
{"date", "2026-03-10T14:00:00+08:00", "start", false, false},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.emitted, func(t *testing.T) {
|
||||
// Fresh command tree per case: ParseFlags mutates flag state.
|
||||
root := NewRootCommand()
|
||||
target := mustFindCommand(t, root, "calendar", "event", "list")
|
||||
|
||||
ctx := &pipeline.Context{
|
||||
Args: []string{"calendar", "event", "list", "--" + tc.emitted, tc.value},
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: pipeline.FlagInfoFromCommand(target),
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error = %v", err)
|
||||
}
|
||||
|
||||
parsedFlag := tc.canonical
|
||||
if tc.native {
|
||||
parsedFlag = tc.emitted
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("native --%s triggered central corrections: %#v", tc.emitted, ctx.Corrections)
|
||||
}
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.emitted+" "+tc.value) {
|
||||
t.Fatalf("native --%s did not survive unchanged: args = %v", tc.emitted, ctx.Args)
|
||||
}
|
||||
} else {
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--"+tc.canonical+" "+tc.value) {
|
||||
t.Fatalf("--%s not reduced to --%s: args = %v", tc.emitted, tc.canonical, ctx.Args)
|
||||
}
|
||||
if len(ctx.Corrections) != 1 {
|
||||
t.Fatalf("central --%s corrections = %#v, want one", tc.emitted, ctx.Corrections)
|
||||
}
|
||||
}
|
||||
|
||||
flagArgs := ctx.Args[3:]
|
||||
if err := target.ParseFlags(flagArgs); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags(%v) error = %v", flagArgs, err)
|
||||
}
|
||||
if tc.isInt {
|
||||
got, err := target.Flags().GetInt(parsedFlag)
|
||||
if err != nil || got != 50 {
|
||||
t.Fatalf("flag --%s = %d (err %v), want 50", parsedFlag, got, err)
|
||||
}
|
||||
} else {
|
||||
got, err := target.Flags().GetString(parsedFlag)
|
||||
if err != nil || got != tc.value {
|
||||
t.Fatalf("flag --%s = %q (err %v), want %q", parsedFlag, got, err, tc.value)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalendarEventListKeepsCountExclusion pins the reviewed decision that
|
||||
// pagination_size deliberately excludes --count (count != limit). The kept
|
||||
// hidden --count flag must be left untouched by the pipeline: it is a real
|
||||
// flag, not a concept member, so it must not be rewritten to --limit.
|
||||
func TestCalendarEventListKeepsCountExclusion(t *testing.T) {
|
||||
engine := newPipelineEngine()
|
||||
root := NewRootCommand()
|
||||
target := mustFindCommand(t, root, "calendar", "event", "list")
|
||||
|
||||
ctx := &pipeline.Context{
|
||||
Args: []string{"calendar", "event", "list", "--count", "5"},
|
||||
Command: target.CommandPath(),
|
||||
FlagSpecs: pipeline.FlagInfoFromCommand(target),
|
||||
}
|
||||
if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil {
|
||||
t.Fatalf("PreParse error = %v", err)
|
||||
}
|
||||
if joined := strings.Join(ctx.Args, " "); !strings.Contains(joined, "--count 5") {
|
||||
t.Fatalf("--count must not be rewritten: args = %v", ctx.Args)
|
||||
}
|
||||
if len(ctx.Corrections) != 0 {
|
||||
t.Fatalf("--count triggered corrections %#v, want none", ctx.Corrections)
|
||||
}
|
||||
if err := target.ParseFlags(ctx.Args[3:]); err != nil {
|
||||
t.Fatalf("Cobra ParseFlags error = %v", err)
|
||||
}
|
||||
if got, _ := target.Flags().GetInt("count"); got != 5 {
|
||||
t.Fatalf("flag --count = %d, want 5", got)
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ var (
|
||||
patPollDeviceFlowWithInterval = pollPatDeviceFlowWithInterval
|
||||
patSaveAppConfig = authpkg.SaveAppConfig
|
||||
patExchangeCodeForToken = authpkg.ExchangeCodeForToken
|
||||
patSaveTokenData = authpkg.SaveTokenData
|
||||
patSaveTokenData = authpkg.SaveLoginTokenData
|
||||
patSleep = time.Sleep
|
||||
patPollHTTPDo = (*http.Client).Do
|
||||
patPollNewRequest = http.NewRequestWithContext
|
||||
@@ -569,8 +569,9 @@ func handlePatAuthCheck(
|
||||
// In host-controlled PAT mode (driven solely by DINGTALK_DWS_AGENTCODE),
|
||||
// or when flowId is absent, the CLI returns machine-readable JSON to
|
||||
// stderr and leaves UI/polling/retry to the host. `claw-type` is NOT
|
||||
// used for this decision — it is only forwarded on the wire via
|
||||
// edition.MergeHeaders and surfaced in hostControl for traceability.
|
||||
// used for this decision — its edition-fixed value is forwarded on the
|
||||
// wire and surfaced in hostControl for traceability. DWS_AGENT_PRODUCT
|
||||
// does not affect this PAT contract.
|
||||
if hostOwnedPAT || patData.Data.FlowID == "" {
|
||||
if hostOwnedPAT {
|
||||
return executor.Result{}, &apperrors.PATError{RawJSON: enrichPATErrorForHostControl(patErr.RawJSON)}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user